From 573e515e967c83afb184a5731d9008d8af76dadd Mon Sep 17 00:00:00 2001 From: SDD Controller <3155001701@qq.com> Date: Thu, 30 Jul 2026 22:53:42 +0800 Subject: [PATCH 01/25] snapshot: pre-SDD baseline (working state with broken real mode) --- .coveragerc | 1 + .flake8 | 3 + .github/workflows/ci.yml | 102 + .github/workflows/cla.yml | 31 + .gitignore | 36 + .yapfignore | 25 + CHANGELOG.md | 173 + CODE-OF-CONDUCT.md | 50 + CONTRIBUTING.md | 149 + INSTALL.md | 316 ++ INSTALL.zh_CN.md | 318 ++ LICENSE | 135 + README.md | 611 ++++ README.zh_CN.md | 612 ++++ build.sh | 14 + build_mac.sh | 14 + clean.sh | 31 + coverage.sh | 15 + dist_pkg.sh | 24 + docs/mkdocs/assets/imgs/agent_cancel.png | Bin 0 -> 68742 bytes docs/mkdocs/assets/imgs/architecture.png | Bin 0 -> 138909 bytes .../assets/imgs/claude_agent_architecture.png | Bin 0 -> 99756 bytes docs/mkdocs/assets/imgs/container0.png | Bin 0 -> 275143 bytes docs/mkdocs/assets/imgs/container1.png | Bin 0 -> 60080 bytes docs/mkdocs/assets/imgs/eval_run.png | Bin 0 -> 489547 bytes docs/mkdocs/assets/imgs/eval_trace.png | Bin 0 -> 465901 bytes .../mkdocs/assets/imgs/graph_architecture.png | Bin 0 -> 145621 bytes docs/mkdocs/assets/imgs/human_in_the_loop.png | Bin 0 -> 80852 bytes docs/mkdocs/assets/imgs/local0.png | Bin 0 -> 169043 bytes docs/mkdocs/assets/imgs/local1.png | Bin 0 -> 101370 bytes .../assets/imgs/optimization_quickstart.png | Bin 0 -> 307225 bytes docs/mkdocs/en/a2a.md | 502 +++ docs/mkdocs/en/agui.md | 335 ++ docs/mkdocs/en/cancel.md | 1073 ++++++ docs/mkdocs/en/claude_agent.md | 716 ++++ docs/mkdocs/en/code_executor.md | 548 +++ docs/mkdocs/en/custom_agent.md | 261 ++ docs/mkdocs/en/evaluation.md | 2675 ++++++++++++++ docs/mkdocs/en/filter.md | 262 ++ docs/mkdocs/en/graph.md | 723 ++++ docs/mkdocs/en/human_in_the_loop.md | 435 +++ docs/mkdocs/en/knowledge.md | 679 ++++ docs/mkdocs/en/knowledge_custom_components.md | 345 ++ docs/mkdocs/en/knowledge_document_loader.md | 138 + docs/mkdocs/en/knowledge_embedder.md | 96 + docs/mkdocs/en/knowledge_prompt_template.md | 145 + docs/mkdocs/en/knowledge_retrievers.md | 95 + docs/mkdocs/en/knowledge_text_splitter.md | 98 + docs/mkdocs/en/knowledge_vectorstore.md | 646 ++++ docs/mkdocs/en/langgraph_agent.md | 570 +++ docs/mkdocs/en/llm_agent.md | 899 +++++ docs/mkdocs/en/memory.md | 1814 ++++++++++ docs/mkdocs/en/model.md | 624 ++++ docs/mkdocs/en/multi_agents.md | 659 ++++ docs/mkdocs/en/openclaw.md | 403 +++ docs/mkdocs/en/optimization.md | 2030 +++++++++++ docs/mkdocs/en/session.md | 960 +++++ docs/mkdocs/en/session_redis.md | 1257 +++++++ docs/mkdocs/en/session_sql.md | 847 +++++ docs/mkdocs/en/session_summary.md | 579 +++ docs/mkdocs/en/skill.md | 2198 +++++++++++ docs/mkdocs/en/sub_agent.md | 272 ++ docs/mkdocs/en/team.md | 880 +++++ docs/mkdocs/en/tool.md | 3201 ++++++++++++++++ docs/mkdocs/zh/a2a.md | 502 +++ docs/mkdocs/zh/agui.md | 335 ++ docs/mkdocs/zh/cancel.md | 1072 ++++++ docs/mkdocs/zh/claude_agent.md | 716 ++++ docs/mkdocs/zh/code_executor.md | 539 +++ docs/mkdocs/zh/custom_agent.md | 261 ++ docs/mkdocs/zh/evaluation.md | 2673 ++++++++++++++ docs/mkdocs/zh/filter.md | 262 ++ docs/mkdocs/zh/graph.md | 722 ++++ docs/mkdocs/zh/human_in_the_loop.md | 437 +++ docs/mkdocs/zh/knowledge.md | 678 ++++ docs/mkdocs/zh/knowledge_custom_components.md | 345 ++ docs/mkdocs/zh/knowledge_document_loader.md | 138 + docs/mkdocs/zh/knowledge_embedder.md | 96 + docs/mkdocs/zh/knowledge_prompt_template.md | 145 + docs/mkdocs/zh/knowledge_retrievers.md | 94 + docs/mkdocs/zh/knowledge_text_splitter.md | 98 + docs/mkdocs/zh/knowledge_vectorstore.md | 646 ++++ docs/mkdocs/zh/langgraph_agent.md | 570 +++ docs/mkdocs/zh/llm_agent.md | 900 +++++ docs/mkdocs/zh/memory.md | 1722 +++++++++ docs/mkdocs/zh/model.md | 611 ++++ docs/mkdocs/zh/multi_agents.md | 656 ++++ docs/mkdocs/zh/openclaw.md | 403 +++ docs/mkdocs/zh/optimization.md | 2038 +++++++++++ docs/mkdocs/zh/session.md | 960 +++++ docs/mkdocs/zh/session_redis.md | 1263 +++++++ docs/mkdocs/zh/session_sql.md | 847 +++++ docs/mkdocs/zh/session_summary.md | 579 +++ docs/mkdocs/zh/skill.md | 2244 ++++++++++++ docs/mkdocs/zh/sub_agent.md | 272 ++ docs/mkdocs/zh/team.md | 880 +++++ docs/mkdocs/zh/tool.md | 3203 +++++++++++++++++ ...6-07-30-eval-optimize-loop-optimization.md | 1877 ++++++++++ examples/a2a/.env | 4 + examples/a2a/README.md | 137 + examples/a2a/__init__.py | 26 + examples/a2a/agent/__init__.py | 10 + examples/a2a/agent/agent.py | 43 + examples/a2a/agent/config.py | 19 + examples/a2a/agent/prompts.py | 11 + examples/a2a/agent/tools.py | 49 + examples/a2a/run_server.py | 71 + examples/a2a/test_a2a.py | 156 + examples/a2a_with_cancel/.env | 4 + examples/a2a_with_cancel/README.md | 309 ++ examples/a2a_with_cancel/__init__.py | 25 + examples/a2a_with_cancel/agent/__init__.py | 10 + examples/a2a_with_cancel/agent/agent.py | 42 + examples/a2a_with_cancel/agent/config.py | 19 + examples/a2a_with_cancel/agent/prompts.py | 11 + examples/a2a_with_cancel/agent/tools.py | 54 + examples/a2a_with_cancel/run_server.py | 77 + examples/a2a_with_cancel/test_a2a_cancel.py | 267 ++ examples/agent_tools/.env | 4 + examples/agent_tools/README.md | 116 + examples/agent_tools/agent/__init__.py | 5 + examples/agent_tools/agent/agent.py | 41 + examples/agent_tools/agent/config.py | 19 + examples/agent_tools/agent/prompts.py | 14 + examples/agent_tools/agent/tools.py | 34 + examples/agent_tools/run_agent.py | 81 + examples/agui/.env | 4 + examples/agui/README.md | 143 + examples/agui/_agui_runner.py | 138 + examples/agui/agent/__init__.py | 5 + examples/agui/agent/agent.py | 57 + examples/agui/agent/config.py | 19 + examples/agui/agent/prompts.py | 31 + examples/agui/agent/tools.py | 36 + examples/agui/client_js/main.js | 62 + examples/agui/client_js/package.json | 16 + examples/agui/run_server.py | 42 + examples/agui_with_cancel/.env | 4 + examples/agui_with_cancel/README.md | 156 + examples/agui_with_cancel/_agui_runner.py | 113 + examples/agui_with_cancel/agent/__init__.py | 5 + examples/agui_with_cancel/agent/agent.py | 43 + examples/agui_with_cancel/agent/config.py | 19 + examples/agui_with_cancel/agent/prompts.py | 11 + examples/agui_with_cancel/agent/tools.py | 42 + examples/agui_with_cancel/client_js/main.js | 58 + .../agui_with_cancel/client_js/package.json | 16 + examples/agui_with_cancel/run_server.py | 42 + examples/claude_agent/.env | 4 + examples/claude_agent/README.md | 119 + examples/claude_agent/agent/__init__.py | 5 + examples/claude_agent/agent/agent.py | 44 + examples/claude_agent/agent/config.py | 19 + examples/claude_agent/agent/prompts.py | 11 + examples/claude_agent/agent/tools.py | 38 + examples/claude_agent/run_agent.py | 105 + examples/claude_agent_with_cancel/.env | 4 + examples/claude_agent_with_cancel/README.md | 162 + .../agent/__init__.py | 5 + .../claude_agent_with_cancel/agent/agent.py | 48 + .../claude_agent_with_cancel/agent/config.py | 19 + .../claude_agent_with_cancel/agent/prompts.py | 10 + .../claude_agent_with_cancel/agent/tools.py | 44 + .../claude_agent_with_cancel/run_agent.py | 309 ++ examples/claude_agent_with_code_writer/.env | 4 + .../claude_agent_with_code_writer/README.md | 136 + .../agent/__init__.py | 5 + .../agent/agent.py | 52 + .../agent/config.py | 19 + .../agent/prompts.py | 8 + .../run_agent.py | 101 + examples/claude_agent_with_skills/.env | 4 + examples/claude_agent_with_skills/README.md | 142 + .../agent/__init__.py | 5 + .../claude_agent_with_skills/agent/agent.py | 69 + .../claude_agent_with_skills/agent/config.py | 19 + .../claude_agent_with_skills/agent/prompts.py | 11 + .../claude_agent_with_skills/agent/tools.py | 13 + .../claude_agent_with_skills/run_agent.py | 79 + .../claude_agent_with_streaming_tool/.env | 4 + .../README.md | 182 + .../agent/__init__.py | 5 + .../agent/agent.py | 63 + .../agent/config.py | 19 + .../agent/prompts.py | 35 + .../agent/tools.py | 71 + .../run_agent.py | 149 + .../claude_agent_with_travel_planner/.env | 4 + .../README.md | 171 + .../agent/__init__.py | 5 + .../agent/agent.py | 61 + .../agent/config.py | 19 + .../agent/prompts.py | 12 + .../agent/tools.py | 34 + .../run_agent.py | 103 + examples/code_executors/.env | 4 + examples/code_executors/README.md | 211 ++ examples/code_executors/agent/__init__.py | 5 + examples/code_executors/agent/agent.py | 80 + examples/code_executors/agent/config.py | 19 + examples/code_executors/agent/prompts.py | 20 + examples/code_executors/agent/tools.py | 6 + examples/code_executors/cube_demo.py | 108 + examples/code_executors/run_agent.py | 97 + examples/dsl/README.md | 10 + examples/dsl/classifier_mcp/.env | 18 + examples/dsl/classifier_mcp/README.md | 153 + examples/dsl/classifier_mcp/agent/__init__.py | 10 + examples/dsl/classifier_mcp/agent/agent.py | 132 + .../dsl/classifier_mcp/agent/callbacks.py | 13 + examples/dsl/classifier_mcp/agent/config.py | 64 + examples/dsl/classifier_mcp/agent/nodes.py | 39 + examples/dsl/classifier_mcp/agent/prompts.py | 17 + examples/dsl/classifier_mcp/agent/state.py | 20 + examples/dsl/classifier_mcp/agent/tools.py | 38 + examples/dsl/classifier_mcp/requirements.txt | 56 + examples/dsl/classifier_mcp/run_agent.py | 220 ++ examples/dsl/classifier_mcp/workflow.json | 164 + examples/dynamic_subagent/.env | 4 + examples/dynamic_subagent/README.md | 64 + examples/dynamic_subagent/agent/__init__.py | 7 + examples/dynamic_subagent/agent/agent.py | 78 + examples/dynamic_subagent/agent/config.py | 22 + examples/dynamic_subagent/agent/prompts.py | 38 + examples/dynamic_subagent/agent/tools.py | 108 + examples/dynamic_subagent/run_agent.py | 128 + examples/evaluation/.env | 6 + examples/evaluation/callbacks/.env | 6 + examples/evaluation/callbacks/README.md | 22 + .../evaluation/callbacks/agent/__init__.py | 6 + examples/evaluation/callbacks/agent/agent.py | 53 + .../agent/callbacks_example.evalset.json | 67 + examples/evaluation/callbacks/agent/config.py | 21 + .../callbacks/agent/test_config.json | 27 + .../evaluation/callbacks/test_callbacks.py | 147 + examples/evaluation/context_messages/.env | 1 + .../evaluation/context_messages/README.md | 20 + .../context_messages/agent/__init__.py | 6 + .../context_messages/agent/agent.py | 36 + .../context_messages/agent/config.py | 23 + .../agent/context_example.evalset.json | 47 + .../context_messages/agent/test_config.json | 27 + .../context_messages/test_context_messages.py | 24 + examples/evaluation/custom_runner/.env | 1 + examples/evaluation/custom_runner/README.md | 39 + .../custom_runner/agent/__init__.py | 6 + .../evaluation/custom_runner/agent/agent.py | 36 + .../evaluation/custom_runner/agent/config.py | 21 + .../agent/custom_runner_example.evalset.json | 37 + .../custom_runner/agent/test_config.json | 27 + .../custom_runner/test_custom_runner.py | 41 + examples/evaluation/llm_final_response/.env | 7 + .../evaluation/llm_final_response/README.md | 23 + .../llm_final_response/agent/__init__.py | 6 + .../llm_final_response/agent/agent.py | 47 + .../llm_final_response/agent/config.py | 24 + .../agent/llm_final_response.evalset.json | 31 + .../llm_final_response/agent/test_config.json | 19 + .../test_llm_final_response.py | 24 + examples/evaluation/llm_judge_tools/.env | 1 + examples/evaluation/llm_judge_tools/README.md | 63 + .../llm_judge_tools/agent/__init__.py | 6 + .../evaluation/llm_judge_tools/agent/agent.py | 47 + .../llm_judge_tools/agent/config.py | 22 + .../agent/judge_tools.evalset.json | 31 + .../llm_judge_tools/agent/test_config.json | 53 + .../llm_judge_tools/test_llm_judge_tools.py | 41 + .../llm_rubric_knowledge_recall/.env | 8 + .../llm_rubric_knowledge_recall/README.md | 23 + .../agent/__init__.py | 6 + .../agent/agent.py | 55 + .../agent/config.py | 22 + .../llm_rubric_knowledge_recall.evalset.json | 49 + .../agent/test_config.json | 32 + .../test_llm_rubric_knowledge_recall.py | 24 + examples/evaluation/llm_rubric_response/.env | 1 + .../evaluation/llm_rubric_response/README.md | 23 + .../llm_rubric_response/agent/__init__.py | 6 + .../llm_rubric_response/agent/agent.py | 47 + .../llm_rubric_response/agent/config.py | 22 + .../agent/llm_rubric_response.evalset.json | 31 + .../agent/test_config.json | 31 + .../test_llm_rubric_response.py | 24 + examples/evaluation/pass_at_k/.env | 1 + examples/evaluation/pass_at_k/README.md | 22 + .../evaluation/pass_at_k/agent/__init__.py | 6 + examples/evaluation/pass_at_k/agent/agent.py | 45 + examples/evaluation/pass_at_k/agent/config.py | 22 + .../pass_at_k/agent/test_config.json | 28 + .../agent/weather_agent.evalset.json | 37 + .../evaluation/pass_at_k/test_pass_at_k.py | 39 + examples/evaluation/quickstart/.env | 1 + examples/evaluation/quickstart/README.md | 27 + .../evaluation/quickstart/agent/__init__.py | 6 + examples/evaluation/quickstart/agent/agent.py | 78 + .../evaluation/quickstart/agent/config.py | 22 + .../quickstart/agent/test_config.json | 27 + .../agent/weather_agent.evalset.json | 47 + .../evaluation/quickstart/test_quickstart.py | 22 + examples/evaluation/trace_mode/.env | 1 + examples/evaluation/trace_mode/README.md | 20 + .../evaluation/trace_mode/agent/__init__.py | 6 + examples/evaluation/trace_mode/agent/agent.py | 31 + .../evaluation/trace_mode/agent/config.py | 21 + .../trace_mode/agent/test_config.json | 27 + .../agent/trace_example.evalset.json | 55 + .../evaluation/trace_mode/test_trace_mode.py | 22 + examples/evaluation/webui/.env | 1 + examples/evaluation/webui/README.md | 36 + examples/evaluation/webui/agent/__init__.py | 6 + .../evaluation/webui/agent/agent.evalset.json | 101 + examples/evaluation/webui/agent/agent.py | 42 + examples/evaluation/webui/agent/config.py | 24 + examples/evaluation/webui/agent/prompts.py | 40 + .../evaluation/webui/agent/test_config.json | 6 + examples/evaluation/webui/agent/tools.py | 159 + examples/evaluation/webui/test_book_finder.py | 21 + examples/fastapi_server/.env | 4 + examples/fastapi_server/README.md | 359 ++ examples/fastapi_server/__init__.py | 35 + examples/fastapi_server/_app.py | 283 ++ examples/fastapi_server/_runner_manager.py | 162 + examples/fastapi_server/_schemas.py | 63 + examples/fastapi_server/agent/__init__.py | 5 + examples/fastapi_server/agent/agent.py | 37 + examples/fastapi_server/agent/config.py | 19 + examples/fastapi_server/agent/prompts.py | 8 + examples/fastapi_server/agent/tools.py | 29 + examples/fastapi_server/run_server.py | 160 + examples/fastapi_server/test/client.py | 324 ++ examples/fastapi_server/test/curl_cli.sh | 21 + examples/file_tools/.env | 4 + examples/file_tools/README.md | 161 + examples/file_tools/agent/__init__.py | 10 + examples/file_tools/agent/agent.py | 64 + examples/file_tools/agent/config.py | 16 + examples/file_tools/agent/prompts.py | 35 + examples/file_tools/run_agent.py | 126 + examples/filter_with_agent/.env | 4 + examples/filter_with_agent/README.md | 195 + examples/filter_with_agent/agent/__init__.py | 5 + examples/filter_with_agent/agent/agent.py | 42 + examples/filter_with_agent/agent/config.py | 19 + examples/filter_with_agent/agent/filter.py | 40 + examples/filter_with_agent/agent/prompts.py | 8 + examples/filter_with_agent/agent/tools.py | 29 + examples/filter_with_agent/run_agent.py | 87 + examples/filter_with_model/.env | 4 + examples/filter_with_model/README.md | 147 + examples/filter_with_model/agent/__init__.py | 5 + examples/filter_with_model/agent/agent.py | 46 + examples/filter_with_model/agent/config.py | 19 + examples/filter_with_model/agent/filter.py | 42 + examples/filter_with_model/agent/prompts.py | 8 + examples/filter_with_model/agent/tools.py | 29 + examples/filter_with_model/run_agent.py | 87 + examples/filter_with_tool/.env | 4 + examples/filter_with_tool/README.md | 126 + examples/filter_with_tool/agent/__init__.py | 5 + examples/filter_with_tool/agent/agent.py | 40 + examples/filter_with_tool/agent/config.py | 19 + examples/filter_with_tool/agent/filter.py | 40 + examples/filter_with_tool/agent/prompts.py | 8 + examples/filter_with_tool/agent/tools.py | 29 + examples/filter_with_tool/run_agent.py | 87 + examples/function_tools/.env | 4 + examples/function_tools/README.md | 148 + examples/function_tools/agent/__init__.py | 5 + examples/function_tools/agent/agent.py | 48 + examples/function_tools/agent/config.py | 19 + examples/function_tools/agent/prompts.py | 10 + examples/function_tools/agent/tools.py | 148 + examples/function_tools/run_agent.py | 84 + examples/goal_tools/.env | 4 + examples/goal_tools/README.md | 100 + examples/goal_tools/agent/__init__.py | 5 + examples/goal_tools/agent/agent.py | 76 + examples/goal_tools/agent/config.py | 21 + examples/goal_tools/agent/prompts.py | 9 + examples/goal_tools/run_agent.py | 295 ++ examples/graph/.env | 4 + examples/graph/README.md | 364 ++ examples/graph/agent/__init__.py | 5 + examples/graph/agent/agent.py | 275 ++ examples/graph/agent/callbacks.py | 123 + examples/graph/agent/config.py | 36 + examples/graph/agent/nodes.py | 276 ++ examples/graph/agent/prompts.py | 21 + examples/graph/agent/state.py | 25 + examples/graph/agent/tools.py | 93 + examples/graph/mcp_server.py | 61 + examples/graph/run_agent.py | 193 + examples/graph_multi_turns/.env | 4 + examples/graph_multi_turns/README.md | 207 ++ examples/graph_multi_turns/agent/__init__.py | 5 + examples/graph_multi_turns/agent/agent.py | 111 + examples/graph_multi_turns/agent/callbacks.py | 13 + examples/graph_multi_turns/agent/config.py | 19 + examples/graph_multi_turns/agent/nodes.py | 107 + examples/graph_multi_turns/agent/prompts.py | 14 + examples/graph_multi_turns/agent/state.py | 23 + examples/graph_multi_turns/agent/tools.py | 6 + examples/graph_multi_turns/run_agent.py | 165 + examples/graph_with_interrupt/.env | 4 + examples/graph_with_interrupt/README.md | 173 + .../graph_with_interrupt/agent/__init__.py | 5 + examples/graph_with_interrupt/agent/agent.py | 111 + .../graph_with_interrupt/agent/callbacks.py | 13 + examples/graph_with_interrupt/agent/config.py | 19 + examples/graph_with_interrupt/agent/nodes.py | 120 + .../graph_with_interrupt/agent/prompts.py | 14 + examples/graph_with_interrupt/agent/state.py | 25 + examples/graph_with_interrupt/agent/tools.py | 6 + examples/graph_with_interrupt/run_agent.py | 211 ++ .../knowledge_with_custom_components/.env | 4 + .../README.md | 159 + .../agent/__init__.py | 5 + .../agent/agent.py | 78 + .../agent/config.py | 12 + .../agent/prompts.py | 16 + .../agent/tools.py | 196 + .../run_agent.py | 96 + examples/knowledge_with_documentloader/.env | 10 + .../knowledge_with_documentloader/README.md | 194 + .../agent/__init__.py | 5 + .../agent/agent.py | 37 + .../agent/config.py | 19 + .../agent/prompts.py | 16 + .../agent/tools.py | 142 + .../run_agent.py | 117 + examples/knowledge_with_prompt_template/.env | 4 + .../knowledge_with_prompt_template/README.md | 161 + .../agent/__init__.py | 5 + .../agent/agent.py | 32 + .../agent/config.py | 19 + .../agent/prompts.py | 40 + .../agent/tools.py | 116 + .../run_agent.py | 142 + examples/knowledge_with_rag_agent/.env | 4 + examples/knowledge_with_rag_agent/README.md | 169 + .../agent/__init__.py | 5 + .../knowledge_with_rag_agent/agent/agent.py | 37 + .../knowledge_with_rag_agent/agent/config.py | 19 + .../knowledge_with_rag_agent/agent/prompts.py | 16 + .../knowledge_with_rag_agent/agent/tools.py | 84 + .../knowledge_with_rag_agent/run_agent.py | 84 + .../knowledge_with_searchtool_rag_agent/.env | 4 + .../README.md | 165 + .../agent/__init__.py | 5 + .../agent/agent.py | 36 + .../agent/config.py | 19 + .../agent/prompts.py | 16 + .../agent/tools.py | 71 + .../run_agent.py | 77 + examples/knowledge_with_vectorstore/.env | 30 + examples/knowledge_with_vectorstore/README.md | 175 + .../agent/__init__.py | 5 + .../knowledge_with_vectorstore/agent/agent.py | 37 + .../agent/config.py | 78 + .../agent/prompts.py | 16 + .../knowledge_with_vectorstore/agent/tools.py | 199 + .../knowledge_with_vectorstore/run_agent.py | 84 + examples/knowledge_with_vectorstore/test.txt | 3 + examples/langchain_tools/.env | 4 + examples/langchain_tools/README.md | 121 + examples/langchain_tools/agent/__init__.py | 5 + examples/langchain_tools/agent/agent.py | 37 + examples/langchain_tools/agent/config.py | 27 + examples/langchain_tools/agent/prompts.py | 10 + examples/langchain_tools/agent/tools.py | 43 + examples/langchain_tools/run_agent.py | 78 + examples/langgraph_agent/.env | 4 + examples/langgraph_agent/README.md | 138 + examples/langgraph_agent/agent/__init__.py | 6 + examples/langgraph_agent/agent/agent.py | 207 ++ examples/langgraph_agent/agent/config.py | 19 + examples/langgraph_agent/agent/tools.py | 41 + examples/langgraph_agent/run_agent.py | 145 + examples/langgraph_agent_with_cancel/.env | 4 + .../langgraph_agent_with_cancel/README.md | 190 + .../agent/__init__.py | 6 + .../agent/agent.py | 105 + .../agent/config.py | 19 + .../agent/tools.py | 79 + .../langgraph_agent_with_cancel/run_agent.py | 304 ++ .../.env | 4 + .../README.md | 141 + .../agent/__init__.py | 5 + .../agent/agent.py | 132 + .../agent/config.py | 19 + .../agent/prompts.py | 15 + .../agent/tools.py | 22 + .../run_agent.py | 143 + examples/litellm/.env | 4 + examples/litellm/README.md | 132 + examples/litellm/agent/__init__.py | 5 + examples/litellm/agent/agent.py | 35 + examples/litellm/agent/config.py | 18 + examples/litellm/agent/prompts.py | 8 + examples/litellm/agent/tools.py | 56 + examples/litellm/run_agent.py | 82 + examples/llm.sh | 3 + examples/llmagent/.env | 5 + examples/llmagent/README.md | 154 + examples/llmagent/agent/__init__.py | 5 + examples/llmagent/agent/agent.py | 54 + examples/llmagent/agent/config.py | 19 + examples/llmagent/agent/prompts.py | 38 + examples/llmagent/agent/tools.py | 51 + examples/llmagent/run_agent.py | 79 + examples/llmagent_with_branch_filtering/.env | 4 + .../llmagent_with_branch_filtering/README.md | 131 + .../agent/__init__.py | 5 + .../agent/agent.py | 93 + .../agent/config.py | 19 + .../agent/prompts.py | 28 + .../agent/tools.py | 33 + .../run_agent.py | 132 + examples/llmagent_with_cancel/.env | 4 + examples/llmagent_with_cancel/README.md | 181 + .../llmagent_with_cancel/agent/__init__.py | 5 + examples/llmagent_with_cancel/agent/agent.py | 49 + examples/llmagent_with_cancel/agent/config.py | 19 + .../llmagent_with_cancel/agent/prompts.py | 11 + examples/llmagent_with_cancel/agent/tools.py | 44 + examples/llmagent_with_cancel/run_agent.py | 292 ++ examples/llmagent_with_custom_agent/.env | 4 + examples/llmagent_with_custom_agent/README.md | 139 + .../agent/__init__.py | 5 + .../llmagent_with_custom_agent/agent/agent.py | 205 ++ .../agent/config.py | 19 + .../agent/prompts.py | 80 + .../llmagent_with_custom_agent/run_agent.py | 127 + examples/llmagent_with_custom_prompt/.env | 4 + .../llmagent_with_custom_prompt/README.md | 118 + .../agent/__init__.py | 5 + .../agent/agent.py | 97 + .../agent/config.py | 19 + .../agent/prompts.py | 24 + .../agent/tools.py | 35 + .../llmagent_with_custom_prompt/run_agent.py | 113 + examples/llmagent_with_human_in_the_loop/.env | 4 + .../llmagent_with_human_in_the_loop/README.md | 110 + .../agent/__init__.py | 5 + .../agent/agent.py | 55 + .../agent/config.py | 19 + .../agent/prompts.py | 15 + .../agent/tools.py | 49 + .../run_agent.py | 168 + .../llmagent_with_max_history_messages/.env | 4 + .../README.md | 116 + .../agent/__init__.py | 5 + .../agent/agent.py | 40 + .../agent/config.py | 19 + .../agent/prompts.py | 10 + .../run_agent.py | 92 + examples/llmagent_with_model_create_fn/.env | 4 + .../llmagent_with_model_create_fn/README.md | 108 + .../agent/__init__.py | 5 + .../agent/agent.py | 50 + .../agent/config.py | 19 + .../agent/prompts.py | 19 + .../agent/tools.py | 19 + .../run_agent.py | 72 + examples/llmagent_with_model_retry/.env | 14 + examples/llmagent_with_model_retry/README.md | 157 + .../agent/__init__.py | 0 .../llmagent_with_model_retry/agent/agent.py | 43 + .../llmagent_with_model_retry/agent/config.py | 49 + .../agent/prompts.py | 13 + .../llmagent_with_model_retry/agent/tools.py | 40 + .../llmagent_with_model_retry/run_agent.py | 86 + examples/llmagent_with_parallal_tools/.env | 4 + .../llmagent_with_parallal_tools/README.md | 111 + .../agent/__init__.py | 5 + .../agent/agent.py | 64 + .../agent/config.py | 19 + .../agent/prompts.py | 19 + .../agent/tools.py | 78 + .../llmagent_with_parallal_tools/run_agent.py | 89 + examples/llmagent_with_prompt_cache/.env | 4 + examples/llmagent_with_prompt_cache/README.md | 64 + .../agent/__init__.py | 5 + .../llmagent_with_prompt_cache/agent/agent.py | 223 ++ .../agent/config.py | 37 + .../agent/prompts.py | 356 ++ .../llmagent_with_prompt_cache/agent/tools.py | 49 + .../llmagent_with_prompt_cache/run_agent.py | 165 + examples/llmagent_with_schema/.env | 5 + examples/llmagent_with_schema/README.md | 168 + .../llmagent_with_schema/agent/__init__.py | 5 + examples/llmagent_with_schema/agent/agent.py | 111 + examples/llmagent_with_schema/agent/config.py | 19 + .../llmagent_with_schema/agent/prompts.py | 63 + examples/llmagent_with_schema/agent/tools.py | 55 + examples/llmagent_with_schema/run_agent.py | 237 ++ .../.env | 5 + .../README.md | 67 + .../agent/__init__.py | 5 + .../agent/agent.py | 51 + .../agent/config.py | 19 + .../agent/prompts.py | 12 + .../agent/tools.py | 55 + .../run_agent.py | 105 + .../verify.py | 307 ++ .../llmagent_with_streaming_tool_complex/.env | 5 + .../README.md | 85 + .../agent/__init__.py | 5 + .../agent/agent.py | 123 + .../agent/config.py | 19 + .../agent/prompts.py | 36 + .../agent/tools.py | 302 ++ .../run_agent.py | 214 ++ .../llmagent_with_streaming_tool_simple/.env | 5 + .../README.md | 81 + .../agent/__init__.py | 5 + .../agent/agent.py | 61 + .../agent/config.py | 19 + .../agent/prompts.py | 34 + .../agent/tools.py | 35 + .../run_agent.py | 126 + examples/llmagent_with_thinking/.env | 5 + examples/llmagent_with_thinking/README.md | 83 + .../llmagent_with_thinking/agent/__init__.py | 5 + .../llmagent_with_thinking/agent/agent.py | 70 + .../llmagent_with_thinking/agent/config.py | 19 + .../llmagent_with_thinking/agent/prompts.py | 46 + .../llmagent_with_thinking/agent/tools.py | 51 + examples/llmagent_with_thinking/run_agent.py | 134 + .../llmagent_with_timeline_filtering/.env | 5 + .../README.md | 84 + .../agent/__init__.py | 5 + .../agent/agent.py | 42 + .../agent/config.py | 19 + .../agent/prompts.py | 10 + .../run_agent.py | 99 + examples/llmagent_with_tool_prompt/.env | 5 + examples/llmagent_with_tool_prompt/README.md | 82 + .../agent/__init__.py | 5 + .../llmagent_with_tool_prompt/agent/agent.py | 58 + .../llmagent_with_tool_prompt/agent/config.py | 19 + .../agent/prompts.py | 10 + .../llmagent_with_tool_prompt/agent/tools.py | 51 + .../llmagent_with_tool_prompt/run_agent.py | 79 + examples/llmagent_with_user_history/.env | 5 + examples/llmagent_with_user_history/README.md | 81 + .../agent/__init__.py | 5 + .../llmagent_with_user_history/agent/agent.py | 34 + .../agent/config.py | 19 + .../agent/prompts.py | 12 + .../llmagent_with_user_history/agent/tools.py | 25 + .../llmagent_with_user_history/run_agent.py | 90 + examples/mcp_tools/.env | 5 + examples/mcp_tools/README.md | 166 + examples/mcp_tools/agent/__init__.py | 5 + examples/mcp_tools/agent/agent.py | 41 + examples/mcp_tools/agent/config.py | 19 + examples/mcp_tools/agent/prompts.py | 11 + examples/mcp_tools/agent/tools.py | 84 + examples/mcp_tools/mcp_server.py | 69 + examples/mcp_tools/run_agent.py | 120 + examples/mem0_tools/.env | 4 + examples/mem0_tools/README.md | 372 ++ examples/mem0_tools/agent/__init__.py | 5 + examples/mem0_tools/agent/agent.py | 50 + examples/mem0_tools/agent/config.py | 61 + examples/mem0_tools/agent/prompts.py | 13 + examples/mem0_tools/agent/tools.py | 6 + examples/mem0_tools/images/mem0_ai.png | Bin 0 -> 155252 bytes examples/mem0_tools/images/mem0_plat.png | Bin 0 -> 170175 bytes examples/mem0_tools/images/mem0_result.png | Bin 0 -> 285673 bytes .../mem0_tools/images/qdrant_dashboard.png | Bin 0 -> 279862 bytes examples/mem0_tools/images/qdrant_mem.png | Bin 0 -> 44238 bytes examples/mem0_tools/run_agent.py | 85 + examples/memory_service_with_in_memory/.env | 5 + .../memory_service_with_in_memory/README.md | 83 + .../agent/__init__.py | 5 + .../agent/agent.py | 38 + .../agent/config.py | 19 + .../agent/prompts.py | 8 + .../agent/tools.py | 30 + .../run_agent.py | 110 + examples/memory_service_with_mem0/.env | 4 + examples/memory_service_with_mem0/README.md | 785 ++++ .../agent/__init__.py | 5 + .../memory_service_with_mem0/agent/agent.py | 38 + .../memory_service_with_mem0/agent/config.py | 61 + .../memory_service_with_mem0/agent/prompts.py | 8 + .../memory_service_with_mem0/agent/tools.py | 30 + .../images/local_infer_false.png | Bin 0 -> 517193 bytes .../images/local_infer_true.png | Bin 0 -> 516969 bytes .../images/remote_infer_false.png | Bin 0 -> 294806 bytes .../images/remote_infer_true.png | Bin 0 -> 270685 bytes .../memory_service_with_mem0/run_agent.py | 134 + examples/memory_service_with_mempalace/.env | 9 + .../memory_service_with_mempalace/README.md | 251 ++ .../agent/__init__.py | 5 + .../agent/agent.py | 47 + .../agent/config.py | 61 + .../agent/prompts.py | 8 + .../agent/tools.py | 30 + .../run_agent.py | 132 + examples/memory_service_with_redis/.env | 4 + examples/memory_service_with_redis/README.md | 1070 ++++++ .../agent/__init__.py | 5 + .../memory_service_with_redis/agent/agent.py | 38 + .../memory_service_with_redis/agent/config.py | 19 + .../agent/prompts.py | 8 + .../memory_service_with_redis/agent/tools.py | 30 + .../memory_service_with_redis/run_agent.py | 136 + examples/memory_service_with_sql/.env | 4 + examples/memory_service_with_sql/README.md | 80 + .../memory_service_with_sql/agent/__init__.py | 5 + .../memory_service_with_sql/agent/agent.py | 46 + .../memory_service_with_sql/agent/config.py | 19 + .../memory_service_with_sql/agent/prompts.py | 8 + .../memory_service_with_sql/agent/tools.py | 32 + examples/memory_service_with_sql/run_agent.py | 132 + examples/mempalace_mcp/.env | 8 + examples/mempalace_mcp/README.md | 277 ++ examples/mempalace_mcp/agent/__init__.py | 5 + examples/mempalace_mcp/agent/agent.py | 39 + examples/mempalace_mcp/agent/config.py | 19 + examples/mempalace_mcp/agent/prompts.py | 25 + examples/mempalace_mcp/agent/tools.py | 93 + examples/mempalace_mcp/run_agent.py | 107 + examples/mempalace_tools/.env | 13 + examples/mempalace_tools/README.md | 180 + examples/mempalace_tools/agent/__init__.py | 5 + examples/mempalace_tools/agent/agent.py | 60 + examples/mempalace_tools/agent/config.py | 29 + examples/mempalace_tools/agent/prompts.py | 26 + examples/mempalace_tools/agent/tools.py | 6 + examples/mempalace_tools/out.txt | 319 ++ examples/mempalace_tools/run_agent.py | 183 + examples/multi_agent_chain/.env | 4 + examples/multi_agent_chain/README.md | 80 + examples/multi_agent_chain/agent/__init__.py | 5 + examples/multi_agent_chain/agent/agent.py | 73 + examples/multi_agent_chain/agent/config.py | 19 + examples/multi_agent_chain/agent/prompts.py | 5 + examples/multi_agent_chain/agent/tools.py | 5 + examples/multi_agent_chain/run_agent.py | 59 + examples/multi_agent_compose/.env | 4 + examples/multi_agent_compose/README.md | 84 + .../multi_agent_compose/agent/__init__.py | 5 + examples/multi_agent_compose/agent/agent.py | 82 + examples/multi_agent_compose/agent/config.py | 19 + examples/multi_agent_compose/agent/prompts.py | 5 + examples/multi_agent_compose/agent/tools.py | 5 + examples/multi_agent_compose/run_agent.py | 67 + examples/multi_agent_cycle/.env | 4 + examples/multi_agent_cycle/README.md | 86 + examples/multi_agent_cycle/agent/__init__.py | 5 + examples/multi_agent_cycle/agent/agent.py | 110 + examples/multi_agent_cycle/agent/config.py | 19 + examples/multi_agent_cycle/agent/prompts.py | 5 + examples/multi_agent_cycle/agent/tools.py | 5 + examples/multi_agent_cycle/run_agent.py | 70 + examples/multi_agent_parallel/.env | 4 + examples/multi_agent_parallel/README.md | 82 + .../multi_agent_parallel/agent/__init__.py | 5 + examples/multi_agent_parallel/agent/agent.py | 64 + examples/multi_agent_parallel/agent/config.py | 19 + .../multi_agent_parallel/agent/prompts.py | 5 + examples/multi_agent_parallel/agent/tools.py | 5 + examples/multi_agent_parallel/run_agent.py | 72 + examples/multi_agent_start_from_last/.env | 4 + .../multi_agent_start_from_last/README.md | 85 + .../agent/__init__.py | 5 + .../agent/agent.py | 72 + .../agent/config.py | 19 + .../agent/prompts.py | 50 + .../agent/tools.py | 37 + .../multi_agent_start_from_last/run_agent.py | 97 + examples/multi_agent_subagent/.env | 4 + examples/multi_agent_subagent/README.md | 84 + .../multi_agent_subagent/agent/__init__.py | 5 + examples/multi_agent_subagent/agent/agent.py | 102 + examples/multi_agent_subagent/agent/config.py | 19 + .../multi_agent_subagent/agent/prompts.py | 5 + examples/multi_agent_subagent/agent/tools.py | 5 + examples/multi_agent_subagent/run_agent.py | 78 + .../advanced_strategies/README.md | 206 ++ .../advanced_strategies/agent/__init__.py | 0 .../advanced_strategies/agent/agent.py | 134 + .../advanced_strategies/agent/config.py | 33 + .../agent/prompts/system.md | 1 + .../advanced_strategies/compare.py | 100 + .../data/train.evalset.json | 73 + .../advanced_strategies/data/val.evalset.json | 73 + .../optimizer_advanced.json | 48 + .../optimizer_baseline.json | 45 + .../advanced_strategies/run_advanced.py | 70 + .../advanced_strategies/run_baseline.py | 65 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 48 + .../advanced_2026-07-30T17-24-38/result.json | 37 + .../advanced_2026-07-30T17-24-38/summary.txt | 19 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 48 + .../advanced_2026-07-30T17-25-41/result.json | 37 + .../advanced_2026-07-30T17-25-41/summary.txt | 19 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 48 + .../advanced_2026-07-30T17-27-07/result.json | 85 + .../rounds/round_001.json | 37 + .../advanced_2026-07-30T17-27-07/summary.txt | 19 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 45 + .../baseline_2026-07-30T17-24-35/result.json | 172 + .../rounds/round_001.json | 33 + .../rounds/round_002.json | 33 + .../rounds/round_003.json | 33 + .../rounds/round_004.json | 33 + .../baseline_2026-07-30T17-24-35/summary.txt | 19 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 45 + .../baseline_2026-07-30T17-25-38/result.json | 172 + .../rounds/round_001.json | 33 + .../rounds/round_002.json | 33 + .../rounds/round_003.json | 33 + .../rounds/round_004.json | 33 + .../baseline_2026-07-30T17-25-38/summary.txt | 19 + .../baseline_prompts/system_prompt.md | 1 + .../best_prompts/system_prompt.md | 1 + .../config.snapshot.json | 45 + .../baseline_2026-07-30T17-26-38/result.json | 85 + .../rounds/round_001.json | 37 + .../baseline_2026-07-30T17-26-38/summary.txt | 19 + examples/optimization/blackbox_cli/README.md | 205 ++ .../blackbox_cli/agent/__init__.py | 10 + .../blackbox_cli/agent/call_agent.py | 143 + .../optimization/blackbox_cli/optimizer.json | 45 + .../blackbox_cli/run_optimization.py | 89 + .../baseline_prompts/claude_md.md | 1 + .../baseline_prompts/skill_md.md | 6 + .../best_prompts/claude_md.md | 1 + .../best_prompts/skill_md.md | 6 + .../2026-07-30T17-42-38/config.snapshot.json | 45 + .../runs/2026-07-30T17-42-38/result.json | 174 + .../2026-07-30T17-42-38/rounds/round_001.json | 33 + .../2026-07-30T17-42-38/rounds/round_002.json | 33 + .../2026-07-30T17-42-38/rounds/round_003.json | 33 + .../2026-07-30T17-42-38/rounds/round_004.json | 33 + .../runs/2026-07-30T17-42-38/summary.txt | 20 + .../baseline_prompts/claude_md.md | 1 + .../baseline_prompts/skill_md.md | 6 + .../2026-07-30T17-49-15/config.snapshot.json | 45 + .../baseline_prompts/claude_md.md | 1 + .../baseline_prompts/skill_md.md | 6 + .../best_prompts/claude_md.md | 1 + .../best_prompts/skill_md.md | 6 + .../2026-07-30T17-52-59/config.snapshot.json | 45 + .../runs/2026-07-30T17-52-59/result.json | 88 + .../2026-07-30T17-52-59/rounds/round_001.json | 38 + .../runs/2026-07-30T17-52-59/summary.txt | 20 + .../blackbox_cli/train.evalset.json | 62 + .../blackbox_cli/val.evalset.json | 40 + .../blackbox_cli/workspace/CLAUDE.md | 1 + .../optimization/ci_integration/README.md | 243 ++ .../ci_integration/agent/__init__.py | 0 .../ci_integration/agent/agent.py | 156 + .../ci_integration/agent/config.py | 33 + .../ci_integration/agent/prompts/skill.md | 1 + .../ci_integration/agent/prompts/system.md | 10 + .../ci_integration/ci/run_nightly_optimize.sh | 20 + .../ci_integration/ci/run_pr_check.sh | 12 + .../ci_integration/data/test_config.json | 14 + .../ci_integration/data/train.evalset.json | 40 + .../ci_integration/data/val.evalset.json | 40 + .../ci_integration/optimizer.json | 45 + .../ci_integration/run_optimization.py | 97 + .../ci_integration/tests/__init__.py | 0 .../tests/test_agent_quality.py | 62 + .../optimization/eval_optimize_loop/README.md | 334 ++ .../eval_optimize_loop/agent/__init__.py | 5 + .../eval_optimize_loop/agent/agent.py | 94 + .../eval_optimize_loop/agent/config.py | 45 + .../agent/prompts/system.md | 1 + .../eval_optimize_loop/agent/tools.py | 190 + .../data/_generate_evalsets.py | 510 +++ .../data/demo_optimize_result.json | 103 + .../eval_optimize_loop/data/optimizer.json | 44 + .../eval_optimize_loop/data/test_config.json | 30 + .../data/train_baseline.evalset.json | 1469 ++++++++ .../data/val_baseline.evalset.json | 1469 ++++++++ .../optimization/eval_optimize_loop/design.md | 17 + .../eval_optimize_loop/pipeline/__init__.py | 12 + .../eval_optimize_loop/pipeline/_models.py | 203 ++ .../eval_optimize_loop/pipeline/_runner.py | 260 ++ .../pipeline/_stage_acceptance_gate.py | 245 ++ .../pipeline/_stage_audit_trail.py | 297 ++ .../pipeline/_stage_baseline.py | 154 + .../pipeline/_stage_failure_attribution.py | 169 + .../pipeline/_stage_optimization.py | 119 + .../pipeline/_stage_validation.py | 112 + .../eval_optimize_loop/run_pipeline.py | 260 ++ .../eval_optimize_loop/tests/__init__.py | 7 + .../eval_optimize_loop/tests/conftest.py | 39 + .../tests/test_acceptance_gate.py | 235 ++ .../tests/test_failure_attribution.py | 245 ++ .../eval_optimize_loop/tests/test_models.py | 304 ++ .../tests/test_optimizer_config.py | 36 + .../tests/test_pipeline_fake.py | 285 ++ examples/optimization/http_service/README.md | 197 + .../optimization/http_service/optimizer.json | 45 + .../http_service/run_optimization.py | 123 + .../http_service/service/__init__.py | 5 + .../http_service/service/prompts/system.md | 1 + .../http_service/service/server.py | 157 + .../http_service/train.evalset.json | 112 + .../http_service/val.evalset.json | 70 + .../multi_agent_pipeline/README.md | 191 + .../multi_agent_pipeline/optimizer.json | 46 + .../multi_agent_pipeline/pipeline/__init__.py | 5 + .../multi_agent_pipeline/pipeline/config.py | 33 + .../pipeline/orchestrator.py | 131 + .../pipeline/prompts/fact_agent.md | 1 + .../pipeline/prompts/math_agent.md | 1 + .../pipeline/prompts/router.md | 1 + .../pipeline/prompts/summarizer.md | 1 + .../multi_agent_pipeline/run_optimization.py | 105 + .../multi_agent_pipeline/train.evalset.json | 92 + .../multi_agent_pipeline/val.evalset.json | 58 + .../multi_metric_with_judges/README.md | 241 ++ .../agent/__init__.py | 5 + .../multi_metric_with_judges/agent/agent.py | 48 + .../multi_metric_with_judges/agent/config.py | 33 + .../agent/prompts/system.md | 1 + .../multi_metric_with_judges/optimizer.json | 100 + .../run_optimization.py | 123 + .../train.evalset.json | 112 + .../multi_metric_with_judges/val.evalset.json | 70 + examples/optimization/quickstart/README.md | 213 ++ .../optimization/quickstart/agent/__init__.py | 5 + .../optimization/quickstart/agent/agent.py | 103 + .../optimization/quickstart/agent/config.py | 33 + .../quickstart/agent/prompts/skill.md | 1 + .../quickstart/agent/prompts/system.md | 1 + .../optimization/quickstart/optimizer.json | 88 + .../quickstart/run_optimization.py | 167 + .../quickstart/train.evalset.json | 112 + .../optimization/quickstart/val.evalset.json | 70 + .../remote_prompt_store/README.md | 208 ++ .../remote_prompt_store/agent/__init__.py | 5 + .../remote_prompt_store/agent/agent.py | 48 + .../remote_prompt_store/agent/config.py | 33 + .../remote_prompt_store/optimizer.json | 45 + .../remote_prompt_store/run_optimization.py | 161 + .../remote_prompt_store/store/__init__.py | 5 + .../store/fake_kv_store.py | 53 + .../store/prompt_client.py | 85 + .../remote_prompt_store/store/store.json | 4 + .../remote_prompt_store/train.evalset.json | 112 + .../remote_prompt_store/val.evalset.json | 70 + .../slo_runtime_control/README.md | 218 ++ .../slo_runtime_control/agent/__init__.py | 10 + .../slo_runtime_control/agent/agent.py | 47 + .../slo_runtime_control/agent/config.py | 33 + .../agent/prompts/system.md | 1 + .../slo_runtime_control/optimizer.json | 48 + .../slo_runtime_control/run_optimization.py | 143 + .../slo_runtime_control/train.evalset.json | 239 ++ .../slo_runtime_control/val.evalset.json | 123 + examples/quickstart/.env | 4 + examples/quickstart/README.md | 81 + examples/quickstart/agent/__init__.py | 5 + examples/quickstart/agent/agent.py | 37 + examples/quickstart/agent/config.py | 19 + examples/quickstart/agent/prompts.py | 8 + examples/quickstart/agent/tools.py | 29 + examples/quickstart/run_agent.py | 87 + examples/session_service_with_in_memory/.env | 4 + .../session_service_with_in_memory/README.md | 84 + .../agent/__init__.py | 5 + .../agent/agent.py | 37 + .../agent/config.py | 19 + .../agent/prompts.py | 8 + .../agent/tools.py | 30 + .../run_agent.py | 105 + examples/session_service_with_redis/.env | 4 + examples/session_service_with_redis/README.md | 889 +++++ .../agent/__init__.py | 5 + .../session_service_with_redis/agent/agent.py | 37 + .../agent/config.py | 19 + .../agent/prompts.py | 8 + .../session_service_with_redis/agent/tools.py | 32 + .../session_service_with_redis/run_agent.py | 132 + examples/session_service_with_sql/.env | 4 + examples/session_service_with_sql/README.md | 82 + .../agent/__init__.py | 5 + .../session_service_with_sql/agent/agent.py | 45 + .../session_service_with_sql/agent/config.py | 19 + .../session_service_with_sql/agent/prompts.py | 8 + .../session_service_with_sql/agent/tools.py | 32 + .../session_service_with_sql/run_agent.py | 129 + examples/session_state/.env | 4 + examples/session_state/README.md | 89 + examples/session_state/agent/__init__.py | 5 + examples/session_state/agent/agent.py | 86 + examples/session_state/agent/config.py | 19 + examples/session_state/agent/prompts.py | 8 + examples/session_state/agent/tools.py | 62 + examples/session_state/agent/utils.py | 39 + examples/session_state/run_agent.py | 222 ++ examples/session_summarizer/.env | 4 + examples/session_summarizer/README.md | 107 + examples/session_summarizer/agent/__init__.py | 5 + examples/session_summarizer/agent/agent.py | 36 + examples/session_summarizer/agent/config.py | 19 + examples/session_summarizer/agent/filters.py | 198 + examples/session_summarizer/agent/prompts.py | 15 + examples/session_summarizer/agent/tools.py | 6 + examples/session_summarizer/run_agent.py | 239 ++ examples/skills/.env | 4 + examples/skills/README.md | 54 + examples/skills/agent/__init__.py | 5 + examples/skills/agent/agent.py | 41 + examples/skills/agent/config.py | 19 + examples/skills/agent/prompts.py | 39 + examples/skills/agent/tools.py | 62 + examples/skills/run_agent.py | 119 + examples/skills/skills/data_analysis/SKILL.md | 44 + .../references/data_analysis_patterns.md | 67 + .../data_analysis/references/numpy_basics.txt | 36 + .../data_analysis/references/pandas_guide.md | 92 + .../data_analysis/scripts/analyze_csv.py | 90 + .../data_analysis/scripts/describe_data.py | 110 + .../data_analysis/scripts/summarize_data.py | 172 + examples/skills/skills/file_tools/SKILL.md | 34 + examples/skills/skills/file_tools/USAGE.md | 11 + .../skills/file_tools/scripts/write_sample.sh | 9 + examples/skills/skills/python-math/SKILL.md | 31 + .../skills/skills/python-math/scripts/fib.py | 24 + examples/skills/skills/user_file_ops/SKILL.md | 47 + .../user_file_ops/scripts/summarize_file.sh | 33 + examples/skills_hub/.env | 8 + examples/skills_hub/.gitignore | 1 + examples/skills_hub/README.md | 51 + examples/skills_hub/agent/__init__.py | 5 + examples/skills_hub/agent/agent.py | 43 + examples/skills_hub/agent/config.py | 19 + examples/skills_hub/agent/hub.py | 49 + examples/skills_hub/agent/prompts.py | 19 + examples/skills_hub/run_agent.py | 91 + examples/skills_with_container/.env | 4 + examples/skills_with_container/README.md | 65 + .../skills_with_container/agent/__init__.py | 5 + examples/skills_with_container/agent/agent.py | 40 + .../skills_with_container/agent/config.py | 19 + .../skills_with_container/agent/prompts.py | 36 + examples/skills_with_container/agent/tools.py | 118 + examples/skills_with_container/run_agent.py | 115 + .../skills/python-math/SKILL.md | 31 + .../skills/python-math/scripts/fib.py | 24 + examples/skills_with_cube/.env | 9 + examples/skills_with_cube/README.md | 99 + examples/skills_with_cube/agent/__init__.py | 5 + examples/skills_with_cube/agent/agent.py | 41 + examples/skills_with_cube/agent/config.py | 19 + examples/skills_with_cube/agent/prompts.py | 36 + examples/skills_with_cube/agent/tools.py | 116 + examples/skills_with_cube/run_agent.py | 146 + .../skills/python-math/SKILL.md | 31 + .../skills/python-math/scripts/fib.py | 24 + examples/skills_with_dynamic_tools/.env | 4 + examples/skills_with_dynamic_tools/README.md | 54 + .../agent/__init__.py | 5 + .../skills_with_dynamic_tools/agent/agent.py | 45 + .../skills_with_dynamic_tools/agent/config.py | 19 + .../agent/prompts.py | 208 ++ .../agent/tools/__init__.py | 22 + .../agent/tools/_dynamic.py | 25 + .../agent/tools/_skill_tools.py | 65 + .../agent/tools/_tools.py | 83 + .../skills_with_dynamic_tools/run_agent.py | 97 + .../skills/weather-tools/SKILL.md | 81 + examples/spawn_subagent/.env | 4 + .../.trpc_agents/security-auditor.md | 10 + examples/spawn_subagent/README.md | 58 + examples/spawn_subagent/agent/__init__.py | 7 + examples/spawn_subagent/agent/agent.py | 117 + examples/spawn_subagent/agent/config.py | 22 + examples/spawn_subagent/agent/prompts.py | 9 + examples/spawn_subagent/run_agent.py | 142 + examples/spawn_subagent/sample_repo/README.md | 14 + examples/spawn_subagent/sample_repo/app.py | 29 + examples/spawn_subagent/sample_repo/auth.py | 15 + examples/spawn_subagent/sample_repo/cart.py | 15 + examples/spawn_subagent/sample_repo/db.py | 15 + examples/streaming_tools/.env | 4 + examples/streaming_tools/README.md | 51 + examples/streaming_tools/agent/__init__.py | 5 + examples/streaming_tools/agent/agent.py | 45 + examples/streaming_tools/agent/config.py | 20 + examples/streaming_tools/agent/prompts.py | 9 + examples/streaming_tools/agent/tools.py | 38 + examples/streaming_tools/run_agent.py | 92 + examples/task_tools/.env | 4 + examples/task_tools/README.md | 181 + examples/task_tools/agent/__init__.py | 5 + examples/task_tools/agent/agent.py | 54 + examples/task_tools/agent/config.py | 19 + examples/task_tools/agent/prompts.py | 10 + examples/task_tools/run_agent.py | 225 ++ examples/team/.env | 4 + examples/team/README.md | 53 + examples/team/agent/__init__.py | 5 + examples/team/agent/agent.py | 72 + examples/team/agent/config.py | 19 + examples/team/agent/prompts.py | 29 + examples/team/agent/tools.py | 57 + examples/team/run_agent.py | 97 + examples/team_as_sub_agent/.env | 4 + examples/team_as_sub_agent/README.md | 58 + examples/team_as_sub_agent/agent/__init__.py | 10 + examples/team_as_sub_agent/agent/agent.py | 95 + examples/team_as_sub_agent/agent/config.py | 19 + examples/team_as_sub_agent/agent/prompts.py | 29 + examples/team_as_sub_agent/agent/tools.py | 54 + examples/team_as_sub_agent/run_agent.py | 133 + examples/team_human_in_the_loop/.env | 4 + examples/team_human_in_the_loop/README.md | 56 + .../team_human_in_the_loop/agent/__init__.py | 5 + .../team_human_in_the_loop/agent/agent.py | 63 + .../team_human_in_the_loop/agent/config.py | 19 + .../team_human_in_the_loop/agent/prompts.py | 15 + .../team_human_in_the_loop/agent/tools.py | 40 + examples/team_human_in_the_loop/run_agent.py | 170 + examples/team_member_agent_claude/.env | 4 + examples/team_member_agent_claude/README.md | 109 + .../agent/__init__.py | 5 + .../team_member_agent_claude/agent/agent.py | 74 + .../team_member_agent_claude/agent/config.py | 19 + .../team_member_agent_claude/agent/prompts.py | 16 + .../team_member_agent_claude/agent/tools.py | 23 + .../team_member_agent_claude/run_agent.py | 106 + examples/team_member_agent_langgraph/.env | 4 + .../team_member_agent_langgraph/README.md | 52 + .../agent/__init__.py | 5 + .../agent/agent.py | 106 + .../agent/config.py | 19 + .../agent/prompts.py | 16 + .../agent/tools.py | 38 + .../team_member_agent_langgraph/run_agent.py | 97 + examples/team_member_agent_team/.env | 4 + examples/team_member_agent_team/README.md | 52 + .../team_member_agent_team/agent/__init__.py | 5 + .../team_member_agent_team/agent/agent.py | 106 + .../team_member_agent_team/agent/config.py | 19 + .../team_member_agent_team/agent/prompts.py | 58 + .../team_member_agent_team/agent/tools.py | 43 + examples/team_member_agent_team/run_agent.py | 107 + examples/team_member_message_filter/.env | 4 + examples/team_member_message_filter/README.md | 55 + .../agent/__init__.py | 5 + .../team_member_message_filter/agent/agent.py | 109 + .../agent/config.py | 19 + .../agent/prompts.py | 23 + .../team_member_message_filter/agent/tools.py | 56 + .../team_member_message_filter/run_agent.py | 94 + examples/team_parallel_execution/.env | 4 + examples/team_parallel_execution/README.md | 53 + .../team_parallel_execution/agent/__init__.py | 5 + .../team_parallel_execution/agent/agent.py | 92 + .../team_parallel_execution/agent/config.py | 19 + .../team_parallel_execution/agent/prompts.py | 34 + .../team_parallel_execution/agent/tools.py | 105 + examples/team_parallel_execution/run_agent.py | 113 + examples/team_with_cancel/.env | 4 + examples/team_with_cancel/README.md | 56 + examples/team_with_cancel/agent/__init__.py | 6 + examples/team_with_cancel/agent/agent.py | 85 + examples/team_with_cancel/agent/config.py | 19 + examples/team_with_cancel/agent/prompts.py | 52 + examples/team_with_cancel/agent/tools.py | 88 + examples/team_with_cancel/run_agent.py | 318 ++ examples/team_with_skill/.env | 4 + examples/team_with_skill/README.md | 52 + examples/team_with_skill/agent/__init__.py | 5 + examples/team_with_skill/agent/agent.py | 62 + examples/team_with_skill/agent/config.py | 19 + examples/team_with_skill/agent/prompts.py | 41 + examples/team_with_skill/agent/tools.py | 92 + examples/team_with_skill/run_agent.py | 89 + .../skills/leader_research/SKILL.md | 32 + .../leader_research/scripts/gather_points.sh | 20 + examples/todo_tool/.env | 4 + examples/todo_tool/README.md | 150 + examples/todo_tool/agent/__init__.py | 5 + examples/todo_tool/agent/agent.py | 75 + examples/todo_tool/agent/config.py | 19 + examples/todo_tool/agent/prompts.py | 11 + examples/todo_tool/run_agent.py | 185 + .../todo_tool_with_human_in_the_loop/.env | 4 + .../README.md | 193 + .../agent/__init__.py | 5 + .../agent/agent.py | 78 + .../agent/config.py | 19 + .../agent/prompts.py | 11 + .../agent/tools.py | 54 + .../run_agent.py | 280 ++ examples/tools/.env | 6 + examples/tools/README.md | 131 + examples/tools/__init__.py | 5 + examples/tools/agent/.env | 6 + examples/tools/agent/__init__.py | 5 + examples/tools/agent/agent.py | 120 + examples/tools/agent/config.py | 19 + examples/tools/agent/function_tool.py | 140 + examples/tools/agent/langchain_tool.py | 49 + examples/tools/agent/prompts.py | 32 + examples/tools/agent/toolset.py | 122 + examples/tools/run_agent.py | 283 ++ examples/toolsets/.env | 4 + examples/toolsets/README.md | 54 + examples/toolsets/agent/__init__.py | 5 + examples/toolsets/agent/agent.py | 39 + examples/toolsets/agent/config.py | 19 + examples/toolsets/agent/prompts.py | 9 + examples/toolsets/agent/tools.py | 113 + examples/toolsets/run_agent.py | 96 + examples/transfer_agent/.env | 4 + examples/transfer_agent/README.md | 142 + examples/transfer_agent/agent/__init__.py | 10 + examples/transfer_agent/agent/agent.py | 56 + examples/transfer_agent/agent/config.py | 25 + examples/transfer_agent/agent/prompts.py | 16 + examples/transfer_agent/agent/tools.py | 34 + examples/transfer_agent/run_agent.py | 208 ++ examples/webfetch_tool/.env | 4 + examples/webfetch_tool/README.md | 278 ++ examples/webfetch_tool/agent/__init__.py | 5 + examples/webfetch_tool/agent/agent.py | 166 + examples/webfetch_tool/agent/config.py | 19 + examples/webfetch_tool/agent/prompts.py | 28 + examples/webfetch_tool/run_agent.py | 213 ++ examples/websearch_tool/.env | 12 + examples/websearch_tool/README.md | 346 ++ examples/websearch_tool/agent/__init__.py | 5 + examples/websearch_tool/agent/agent.py | 149 + examples/websearch_tool/agent/config.py | 33 + examples/websearch_tool/agent/prompts.py | 46 + examples/websearch_tool/run_agent.py | 245 ++ format.py | 1225 +++++++ format.sh | 12 + lint_flake8.sh | 21 + pyproject.toml | 208 ++ requirements-test.txt | 56 + requirements.txt | 54 + run_example.sh | 4 + stop.sh | 5 + tests/__init__.py | 5 + tests/abc/__init__.py | 6 + tests/abc/conftest.py | 56 + tests/abc/test_agent.py | 152 + tests/abc/test_artifact_service.py | 147 + tests/abc/test_filter.py | 162 + tests/abc/test_memory_service.py | 117 + tests/abc/test_session.py | 137 + tests/abc/test_toolset.py | 164 + tests/agents/__init__.py | 6 + tests/agents/core/__init__.py | 6 + .../core/test_agent_transfer_processor.py | 264 ++ .../core/test_code_execution_processor.py | 80 + tests/agents/core/test_history_processor.py | 268 ++ tests/agents/core/test_llm_processor.py | 212 ++ .../core/test_output_schema_processor.py | 201 ++ tests/agents/core/test_request_processor.py | 322 ++ .../agents/core/test_request_processor_ext.py | 670 ++++ tests/agents/core/test_skill_processor.py | 224 ++ .../core/test_skill_tool_result_processor.py | 136 + tests/agents/core/test_tools_processor.py | 445 +++ .../core/test_workspace_exec_processor.py | 127 + tests/agents/sub_agent/__init__.py | 6 + tests/agents/sub_agent/test_archetype.py | 103 + tests/agents/sub_agent/test_defaults.py | 90 + tests/agents/sub_agent/test_description.py | 117 + .../sub_agent/test_dynamic_sub_agent_tool.py | 315 ++ tests/agents/sub_agent/test_imports.py | 86 + tests/agents/sub_agent/test_loader.py | 440 +++ tests/agents/sub_agent/test_registry.py | 77 + tests/agents/sub_agent/test_runner.py | 958 +++++ .../sub_agent/test_spawn_sub_agent_tool.py | 269 ++ tests/agents/test_base_agent.py | 218 ++ tests/agents/test_callback.py | 250 ++ tests/agents/test_chain_agent.py | 154 + tests/agents/test_constants.py | 59 + tests/agents/test_cycle_agent.py | 189 + tests/agents/test_langgraph_agent.py | 528 +++ tests/agents/test_llm_agent.py | 332 ++ tests/agents/test_llm_agent_ext.py | 413 +++ tests/agents/test_parallel_agent.py | 180 + tests/agents/test_parallel_agent_ext.py | 316 ++ tests/agents/test_transfer_agent.py | 381 ++ tests/agents/utils/__init__.py | 6 + .../utils/test_langgraph_event_writer.py | 208 ++ tests/artifacts/__init__.py | 6 + tests/artifacts/conftest.py | 57 + .../test_in_memory_artifact_service.py | 601 ++++ tests/artifacts/test_utils.py | 306 ++ tests/cancel/__init__.py | 0 tests/cancel/test_cancel.py | 303 ++ tests/cancel/test_session_utils.py | 346 ++ tests/code_executors/__init__.py | 5 + tests/code_executors/container/__init__.py | 0 .../container/test_container_cli.py | 470 +++ .../container/test_container_code_executor.py | 332 ++ .../container/test_container_ws_runtime.py | 1435 ++++++++ tests/code_executors/cube/__init__.py | 0 tests/code_executors/cube/conftest.py | 131 + tests/code_executors/cube/test_bug_hunt.py | 631 ++++ .../code_executors/cube/test_code_executor.py | 540 +++ .../cube/test_package_imports.py | 103 + tests/code_executors/cube/test_paths.py | 302 ++ tests/code_executors/cube/test_runtime.py | 817 +++++ tests/code_executors/cube/test_sandbox.py | 607 ++++ tests/code_executors/cube/test_transfer.py | 510 +++ tests/code_executors/cube/test_types.py | 145 + tests/code_executors/local/__init__.py | 0 .../local/test_local_ws_runtime.py | 1098 ++++++ .../local/test_unsafe_local_code_executor.py | 441 +++ tests/code_executors/test_artifacts.py | 187 + .../code_executors/test_base_code_executor.py | 188 + .../test_base_workspace_fs_collect.py | 457 +++ .../test_base_workspace_runtime.py | 387 ++ .../test_code_executor_context.py | 217 ++ tests/code_executors/test_constants.py | 244 ++ .../test_container_container_code_executor.py | 232 ++ .../test_local_unsafe_local_code_executor.py | 272 ++ tests/code_executors/test_types.py | 550 +++ .../test_utils_code_execution.py | 257 ++ tests/code_executors/test_utils_files.py | 390 ++ tests/code_executors/test_utils_workspace.py | 160 + tests/code_executors/utils/__init__.py | 5 + tests/code_executors/utils/test_meta.py | 570 +++ tests/common/__init__.py | 5 + tests/common/test_compatible.py | 355 ++ tests/common/test_init.py | 35 + tests/configs/__init__.py | 5 + tests/configs/test_model_retry_config.py | 69 + tests/configs/test_run_config.py | 314 ++ tests/context/__init__.py | 6 + tests/context/test_agent_context.py | 132 + tests/context/test_common.py | 189 + tests/context/test_constants.py | 16 + tests/context/test_invocation_context.py | 403 +++ tests/evaluation/__init__.py | 5 + tests/evaluation/test_agent_evaluator.py | 99 + .../test_agent_evaluator_call_agent.py | 170 + tests/evaluation/test_agent_optimizer.py | 1285 +++++++ tests/evaluation/test_base_optimizer.py | 240 ++ tests/evaluation/test_criterion_registry.py | 78 + tests/evaluation/test_eval_callbacks.py | 86 + tests/evaluation/test_eval_callbacks_ext.py | 229 ++ tests/evaluation/test_eval_case.py | 105 + tests/evaluation/test_eval_config.py | 112 + tests/evaluation/test_eval_criterion.py | 144 + tests/evaluation/test_eval_criterion_ext.py | 250 ++ tests/evaluation/test_eval_metrics.py | 98 + tests/evaluation/test_eval_pass.py | 88 + tests/evaluation/test_eval_result.py | 217 ++ tests/evaluation/test_eval_service_base.py | 82 + tests/evaluation/test_eval_session_service.py | 148 + tests/evaluation/test_eval_set.py | 43 + .../test_eval_set_results_manager_utils.py | 85 + ...test_eval_set_results_manager_utils_ext.py | 250 ++ .../test_eval_sets_manager_utils.py | 134 + tests/evaluation/test_evaluator_registry.py | 76 + .../test_final_response_evaluator.py | 51 + .../test_in_memory_eval_sets_manager.py | 196 + tests/evaluation/test_llm_criterion.py | 337 ++ .../evaluation/test_llm_evaluator_registry.py | 104 + tests/evaluation/test_llm_judge.py | 122 + .../test_llm_judge_models_aggregator.py | 198 + .../evaluation/test_llm_judge_multi_model.py | 354 ++ tests/evaluation/test_llm_judge_think.py | 393 ++ .../test_local_eval_sets_manager.py | 168 + tests/evaluation/test_optimize_config.py | 629 ++++ .../test_optimize_evaluator_call.py | 613 ++++ .../evaluation/test_optimize_gepa_adapter.py | 1748 +++++++++ .../evaluation/test_optimize_gepa_callback.py | 667 ++++ tests/evaluation/test_optimize_gepa_e2e.py | 210 ++ .../test_optimize_gepa_reflective.py | 1628 +++++++++ tests/evaluation/test_optimize_metric_info.py | 630 ++++ .../test_optimize_model_callable.py | 261 ++ .../evaluation/test_optimize_model_options.py | 113 + .../test_optimize_quickstart_example.py | 489 +++ tests/evaluation/test_optimize_registry.py | 109 + tests/evaluation/test_optimize_reporter.py | 611 ++++ tests/evaluation/test_optimize_result.py | 456 +++ tests/evaluation/test_remote_eval_service.py | 261 ++ tests/evaluation/test_rouge_evaluator.py | 52 + .../evaluation/test_static_user_simulator.py | 49 + tests/evaluation/test_target_prompt.py | 539 +++ tests/evaluation/test_trajectory_evaluator.py | 51 + .../test_trajectory_evaluator_ext.py | 138 + tests/evaluation/test_user_simulator.py | 63 + .../test_user_simulator_provider.py | 58 + tests/evaluation/test_utils.py | 260 ++ tests/evaluation/test_utils_ext.py | 174 + tests/events/__init__.py | 5 + tests/events/test_agent_cancelled_event.py | 193 + tests/events/test_cache_analyzer.py | 178 + tests/events/test_event.py | 646 ++++ tests/events/test_event_translator.py | 189 + tests/events/test_long_running_event.py | 235 ++ tests/events/test_utils.py | 256 ++ tests/exceptions/__init__.py | 6 + tests/exceptions/test_exceptions.py | 314 ++ tests/file_tools/__init__.py | 6 + tests/file_tools/test_bash_tool.py | 190 + tests/file_tools/test_edit_tool.py | 234 ++ tests/file_tools/test_file_utils.py | 126 + tests/file_tools/test_glob_tool.py | 176 + tests/file_tools/test_grep_tool.py | 238 ++ tests/file_tools/test_read_tool.py | 214 ++ tests/file_tools/test_write_tool.py | 203 ++ tests/filter/__init__.py | 6 + tests/filter/test_base_filter.py | 565 +++ tests/filter/test_filter_runner.py | 316 ++ tests/filter/test_registry.py | 202 ++ tests/filter/test_run_filter.py | 339 ++ tests/knowledge/__init__.py | 5 + tests/knowledge/test_filter_expr.py | 390 ++ tests/knowledge/test_knowledge.py | 349 ++ tests/langfuse/__init__.py | 6 + tests/langfuse/prompt/__init__.py | 6 + tests/langfuse/prompt/test_manager.py | 210 ++ tests/langfuse/tracing/__init__.py | 6 + .../test_langfuse_reporting_fixtures.py | 332 ++ tests/langfuse/tracing/test_opentelemetry.py | 1009 ++++++ tests/log/__init__.py | 5 + tests/log/test_base_logger.py | 159 + tests/log/test_default_logger.py | 369 ++ tests/log/test_logger.py | 346 ++ tests/memory/__init__.py | 5 + tests/memory/test_in_memory_memory_service.py | 417 +++ tests/memory/test_mem0_memory_service.py | 696 ++++ tests/memory/test_mempalace_memory_service.py | 179 + tests/memory/test_redis_memory_service.py | 311 ++ tests/memory/test_sql_memory_service.py | 503 +++ tests/memory/test_utils.py | 103 + tests/models/__init__.py | 6 + tests/models/test_anthropic_model.py | 833 +++++ tests/models/test_anthropic_model_ext.py | 522 +++ tests/models/test_litellm_model.py | 1072 ++++++ tests/models/test_llm_request.py | 221 ++ tests/models/test_llm_response.py | 342 ++ tests/models/test_openai_model.py | 1044 ++++++ tests/models/test_openai_model_ext.py | 1311 +++++++ tests/models/test_prompt_cache_config.py | 204 ++ tests/models/test_registry.py | 277 ++ tests/models/test_retry.py | 284 ++ tests/models/test_tool_prompt.py | 529 +++ tests/planners/__init__.py | 5 + tests/planners/test__built_in_planner.py | 159 + tests/planners/test__plan_re_act_planner.py | 528 +++ tests/planners/test__planning_processor.py | 390 ++ tests/server/__init__.py | 5 + tests/server/a2a/__init__.py | 0 tests/server/a2a/converters/__init__.py | 0 .../a2a/converters/test_event_converter.py | 748 ++++ .../a2a/converters/test_part_converter.py | 429 +++ .../a2a/converters/test_request_converter.py | 169 + tests/server/a2a/executor/__init__.py | 0 .../a2a/executor/test_a2a_agent_executor.py | 323 ++ .../executor/test_task_result_aggregator.py | 146 + tests/server/a2a/logs/__init__.py | 0 tests/server/a2a/logs/test_log_utils.py | 260 ++ tests/server/a2a/test_agent_card_builder.py | 562 +++ tests/server/a2a/test_agent_service.py | 152 + tests/server/a2a/test_remote_a2a_agent.py | 472 +++ tests/server/a2a/test_utils.py | 92 + tests/server/ag_ui/__init__.py | 0 tests/server/ag_ui/_core/__init__.py | 0 tests/server/ag_ui/_core/test_agui_agent.py | 1550 ++++++++ .../ag_ui/_core/test_client_proxy_tool.py | 370 ++ .../ag_ui/_core/test_client_proxy_toolset.py | 230 ++ tests/server/ag_ui/_core/test_converters.py | 527 +++ tests/server/ag_ui/_core/test_endpoint.py | 190 + .../ag_ui/_core/test_event_translator.py | 959 +++++ .../ag_ui/_core/test_execution_state.py | 198 + .../ag_ui/_core/test_feed_back_content.py | 97 + tests/server/ag_ui/_core/test_http_req.py | 72 + .../ag_ui/_core/test_session_manager.py | 1041 ++++++ tests/server/ag_ui/_plugin/__init__.py | 0 .../test_langgraph_event_translator.py | 77 + tests/server/ag_ui/_plugin/test_manager.py | 145 + tests/server/ag_ui/_plugin/test_registry.py | 90 + tests/server/ag_ui/_plugin/test_service.py | 167 + tests/server/ag_ui/_plugin/test_utils.py | 240 ++ tests/server/agents/__init__.py | 0 tests/server/agents/claude/__init__.py | 0 .../server/agents/claude/test_claude_agent.py | 1655 +++++++++ tests/server/agents/claude/test_proxy.py | 1175 ++++++ .../server/agents/claude/test_proxy_logger.py | 197 + tests/server/agents/claude/test_runtime.py | 176 + .../agents/claude/test_session_config.py | 41 + .../agents/claude/test_session_manager.py | 562 +++ tests/server/agents/claude/test_setup.py | 400 ++ tests/server/knowledge/__init__.py | 5 + .../knowledge/test_langchain_knowledge.py | 756 ++++ tests/server/knowledge/tools/__init__.py | 5 + .../test_langchain_knowledge_searchtool.py | 438 +++ tests/server/openclaw/__init__.py | 0 tests/server/openclaw/agent/__init__.py | 0 tests/server/openclaw/agent/test_agent.py | 277 ++ tests/server/openclaw/agent/test_prompts.py | 176 + tests/server/openclaw/channels/__init__.py | 0 .../openclaw/channels/test_command_handler.py | 322 ++ tests/server/openclaw/channels/test_repair.py | 100 + tests/server/openclaw/channels/test_wecom.py | 293 ++ tests/server/openclaw/config/__init__.py | 0 tests/server/openclaw/config/test_config.py | 370 ++ tests/server/openclaw/metrics/__init__.py | 0 .../server/openclaw/metrics/test_langfuse.py | 140 + tests/server/openclaw/metrics/test_metrics.py | 109 + tests/server/openclaw/service/__init__.py | 0 .../openclaw/service/test_heart_service.py | 145 + .../openclaw/session_memory/__init__.py | 0 .../test_claw_memory_service.py | 132 + .../test_claw_session_service.py | 296 ++ .../session_memory/test_claw_summarizer.py | 767 ++++ tests/server/openclaw/skill/__init__.py | 0 tests/server/openclaw/skill/test_deps.py | 990 +++++ .../openclaw/skill/test_skill_loader.py | 1486 ++++++++ .../openclaw/skill/test_skill_parser.py | 392 ++ .../server/openclaw/skill/test_skill_tool.py | 228 ++ tests/server/openclaw/skill/test_utils.py | 241 ++ tests/server/openclaw/storage/__init__.py | 0 .../openclaw/storage/test_aiofile_storage.py | 350 ++ tests/server/openclaw/storage/test_manager.py | 788 ++++ tests/server/openclaw/storage/test_utils.py | 109 + tests/server/openclaw/test_claw.py | 1549 ++++++++ tests/server/openclaw/test_cli.py | 209 ++ tests/server/openclaw/test_logger.py | 261 ++ tests/server/openclaw/test_ui.py | 1987 ++++++++++ tests/server/openclaw/test_utils.py | 305 ++ tests/server/openclaw/tools/__init__.py | 5 + tests/server/openclaw/tools/test_cron.py | 348 ++ .../server/openclaw/tools/test_filesystem.py | 529 +++ tests/server/openclaw/tools/test_mcp_tool.py | 212 ++ tests/server/openclaw/tools/test_message.py | 182 + tests/server/openclaw/tools/test_shell.py | 376 ++ .../server/openclaw/tools/test_spawn_task.py | 176 + tests/server/openclaw/tools/test_web.py | 793 ++++ tests/sessions/__init__.py | 5 + tests/sessions/test_base_session_service.py | 349 ++ tests/sessions/test_history_record.py | 127 + .../test_in_memory_session_service.py | 550 +++ tests/sessions/test_redis_session_service.py | 345 ++ tests/sessions/test_session.py | 399 ++ tests/sessions/test_session_summarizer.py | 704 ++++ tests/sessions/test_sql_session_service.py | 458 +++ tests/sessions/test_summarizer_checker.py | 369 ++ tests/sessions/test_summarizer_manager.py | 298 ++ tests/sessions/test_types.py | 121 + tests/sessions/test_utils.py | 295 ++ tests/skills/__init__.py | 1 + tests/skills/hub/__init__.py | 0 tests/skills/hub/test_claude_marketplace.py | 141 + tests/skills/hub/test_clawhub.py | 223 ++ tests/skills/hub/test_github.py | 340 ++ tests/skills/hub/test_hermes_index.py | 219 ++ tests/skills/hub/test_install.py | 294 ++ tests/skills/hub/test_lobehub.py | 134 + tests/skills/hub/test_skills_sh.py | 150 + tests/skills/hub/test_source.py | 56 + tests/skills/hub/test_types.py | 155 + tests/skills/hub/test_well_known.py | 203 ++ tests/skills/stager/__init__.py | 1 + tests/skills/stager/test_base_stager.py | 318 ++ tests/skills/stager/test_types.py | 45 + tests/skills/stager/test_utils.py | 28 + tests/skills/test_common.py | 532 +++ tests/skills/test_constants.py | 23 + tests/skills/test_dynamic_toolset.py | 473 +++ tests/skills/test_hot_reload.py | 35 + tests/skills/test_registry.py | 91 + tests/skills/test_repository.py | 474 +++ tests/skills/test_skill_config.py | 42 + tests/skills/test_skill_profile.py | 41 + tests/skills/test_state_keys.py | 35 + tests/skills/test_state_migration.py | 71 + tests/skills/test_state_order.py | 24 + tests/skills/test_toolset.py | 74 + tests/skills/test_types.py | 392 ++ tests/skills/test_url_root.py | 636 ++++ tests/skills/test_utils.py | 201 ++ tests/skills/tools/__init__.py | 1 + tests/skills/tools/test_common.py | 31 + tests/skills/tools/test_file_stager.py | 142 + tests/skills/tools/test_save_artifact.py | 49 + tests/skills/tools/test_skill_exec.py | 109 + tests/skills/tools/test_skill_list.py | 45 + tests/skills/tools/test_skill_list_docs.py | 66 + tests/skills/tools/test_skill_list_tool.py | 64 + tests/skills/tools/test_skill_load.py | 241 ++ tests/skills/tools/test_skill_run.py | 137 + tests/skills/tools/test_skill_select_docs.py | 108 + tests/skills/tools/test_skill_select_tools.py | 106 + tests/skills/tools/test_workspace_exec.py | 34 + tests/storage/__init__.py | 5 + tests/storage/test_constants.py | 32 + tests/storage/test_db.py | 105 + tests/storage/test_redis.py | 817 +++++ tests/storage/test_sql.py | 520 +++ tests/storage/test_sql_common.py | 609 ++++ tests/teams/__init__.py | 6 + tests/teams/test_delegation_signal.py | 248 ++ tests/teams/test_delegation_tools.py | 209 ++ tests/teams/test_member_message_filter.py | 306 ++ tests/teams/test_message_builder.py | 523 +++ tests/teams/test_system_message.py | 354 ++ tests/teams/test_team_agent.py | 2481 +++++++++++++ tests/teams/test_team_run_context.py | 489 +++ tests/telemetry/__init__.py | 5 + tests/telemetry/test_custom_metrics.py | 224 ++ tests/telemetry/test_custom_trace.py | 619 ++++ tests/telemetry/test_metrics.py | 553 +++ tests/telemetry/test_trace.py | 1181 ++++++ tests/test_cli.py | 98 + tests/test_runner.py | 818 +++++ tests/test_version.py | 13 + tests/tools/__init__.py | 0 tests/tools/goal_tools/test_goal_tools.py | 627 ++++ tests/tools/mcp_tool/__init__.py | 0 .../mcp_tool/test_mcp_session_manager.py | 545 +++ tests/tools/mcp_tool/test_mcp_tool.py | 422 +++ tests/tools/mcp_tool/test_mcp_toolset.py | 523 +++ tests/tools/mcp_tool/test_types.py | 61 + tests/tools/mcp_tool/test_utils.py | 162 + tests/tools/task_tools/test_task_tools.py | 327 ++ tests/tools/test_agent_tool.py | 395 ++ tests/tools/test_base_tool.py | 243 ++ tests/tools/test_constants.py | 35 + tests/tools/test_context_var.py | 57 + tests/tools/test_default_toolset.py | 128 + tests/tools/test_file_utils.py | 71 + tests/tools/test_function_tool.py | 223 ++ tests/tools/test_load_memory_tool.py | 103 + tests/tools/test_long_running_tool.py | 57 + tests/tools/test_mem0_tool.py | 151 + tests/tools/test_mempalace_tool.py | 390 ++ tests/tools/test_preload_memory_tool.py | 152 + tests/tools/test_registry.py | 184 + tests/tools/test_set_model_response_tool.py | 118 + tests/tools/test_streaming_function_tool.py | 122 + tests/tools/test_streaming_progress_tool.py | 138 + tests/tools/test_todo_tool.py | 211 ++ tests/tools/test_tool_adapter.py | 179 + tests/tools/test_transfer_to_agent_tool.py | 39 + tests/tools/test_websearch_tool.py | 1191 ++++++ tests/tools/utils/__init__.py | 0 .../utils/test_automatic_function_calling.py | 163 + .../utils/test_function_parameter_parse.py | 337 ++ tests/tools/utils/test_tool_utils.py | 246 ++ tests/trpc_agent_dsl/__init__.py | 0 tests/trpc_agent_dsl/graph/__init__.py | 1 + tests/trpc_agent_dsl/graph/test_callbacks.py | 116 + tests/trpc_agent_dsl/graph/test_constants.py | 178 + .../graph/test_event_metadata.py | 76 + .../trpc_agent_dsl/graph/test_event_writer.py | 184 + tests/trpc_agent_dsl/graph/test_events.py | 322 ++ .../graph/test_events_constants.py | 101 + .../trpc_agent_dsl/graph/test_graph_agent.py | 303 ++ tests/trpc_agent_dsl/graph/test_interrupt.py | 24 + .../trpc_agent_dsl/graph/test_memory_saver.py | 313 ++ .../graph/test_node_action_agent.py | 394 ++ .../graph/test_node_action_code.py | 93 + .../graph/test_node_action_knowledge.py | 137 + .../graph/test_node_action_llm.py | 518 +++ .../graph/test_node_action_mcp.py | 247 ++ .../trpc_agent_dsl/graph/test_node_config.py | 62 + tests/trpc_agent_dsl/graph/test_state.py | 157 + .../trpc_agent_dsl/graph/test_state_graph.py | 606 ++++ .../trpc_agent_dsl/graph/test_state_mapper.py | 92 + tests/types/__init__.py | 5 + tests/types/test_agent_types.py | 164 + tests/types/test_event_actions.py | 124 + tests/types/test_instruction.py | 127 + tests/types/test_memory.py | 106 + tests/types/test_state.py | 213 ++ tests/types/test_ttl.py | 242 ++ tests/utils/__init__.py | 5 + tests/utils/test_context_utils.py | 120 + tests/utils/test_execute_cmd.py | 150 + tests/utils/test_hash_key.py | 53 + tests/utils/test_init.py | 53 + tests/utils/test_json_repair.py | 176 + tests/utils/test_registry_factory.py | 181 + tests/utils/test_registry_factory_ext.py | 86 + tests/utils/test_singleton.py | 232 ++ trpc_agent_sdk/__init__.py | 6 + trpc_agent_sdk/_cli.py | 229 ++ trpc_agent_sdk/abc/__init__.py | 64 + trpc_agent_sdk/abc/_agent.py | 187 + trpc_agent_sdk/abc/_artifact_service.py | 202 ++ trpc_agent_sdk/abc/_filter.py | 232 ++ trpc_agent_sdk/abc/_memory_service.py | 90 + trpc_agent_sdk/abc/_planner.py | 91 + trpc_agent_sdk/abc/_request.py | 93 + trpc_agent_sdk/abc/_response.py | 123 + trpc_agent_sdk/abc/_session.py | 63 + trpc_agent_sdk/abc/_session_service.py | 127 + trpc_agent_sdk/abc/_tool.py | 96 + trpc_agent_sdk/abc/_toolset.py | 134 + trpc_agent_sdk/agents/__init__.py | 74 + trpc_agent_sdk/agents/_base_agent.py | 343 ++ trpc_agent_sdk/agents/_callback.py | 321 ++ trpc_agent_sdk/agents/_chain_agent.py | 62 + trpc_agent_sdk/agents/_constants.py | 48 + trpc_agent_sdk/agents/_cycle_agent.py | 78 + trpc_agent_sdk/agents/_langgraph_agent.py | 823 +++++ trpc_agent_sdk/agents/_llm_agent.py | 746 ++++ trpc_agent_sdk/agents/_parallel_agent.py | 212 ++ trpc_agent_sdk/agents/_transfer_agent.py | 162 + trpc_agent_sdk/agents/core/README.md | 406 +++ trpc_agent_sdk/agents/core/__init__.py | 56 + .../agents/core/_agent_transfer_processor.py | 211 ++ .../agents/core/_code_execution_processor.py | 439 +++ .../agents/core/_history_processor.py | 359 ++ trpc_agent_sdk/agents/core/_llm_processor.py | 256 ++ .../agents/core/_output_schema_processor.py | 106 + .../agents/core/_request_processor.py | 999 +++++ .../agents/core/_skill_processor.py | 801 +++++ .../core/_skills_tool_result_processor.py | 378 ++ .../agents/core/_tools_processor.py | 775 ++++ .../agents/core/_workspace_exec_processor.py | 153 + trpc_agent_sdk/agents/sub_agent/__init__.py | 52 + trpc_agent_sdk/agents/sub_agent/_archetype.py | 89 + trpc_agent_sdk/agents/sub_agent/_constants.py | 30 + trpc_agent_sdk/agents/sub_agent/_defaults.py | 195 + .../agents/sub_agent/_description.py | 74 + .../sub_agent/_dynamic_sub_agent_tool.py | 229 ++ trpc_agent_sdk/agents/sub_agent/_loader.py | 229 ++ trpc_agent_sdk/agents/sub_agent/_registry.py | 55 + trpc_agent_sdk/agents/sub_agent/_runner.py | 342 ++ .../agents/sub_agent/_spawn_sub_agent_tool.py | 208 ++ .../agents/sub_agent/_sub_agent_config.py | 46 + trpc_agent_sdk/agents/utils/__init__.py | 44 + trpc_agent_sdk/agents/utils/_langgraph.py | 354 ++ .../agents/utils/_langgraph_event_writer.py | 232 ++ trpc_agent_sdk/artifacts/__init__.py | 34 + .../artifacts/_in_memory_artifact_service.py | 198 + trpc_agent_sdk/artifacts/_utils.py | 161 + trpc_agent_sdk/cancel/__init__.py | 28 + trpc_agent_sdk/cancel/_cancel.py | 226 ++ trpc_agent_sdk/cancel/_session_utils.py | 109 + trpc_agent_sdk/code_executors/__init__.py | 180 + trpc_agent_sdk/code_executors/_artifacts.py | 90 + .../code_executors/_base_code_executor.py | 120 + .../code_executors/_base_workspace_runtime.py | 558 +++ .../code_executors/_code_executor_context.py | 147 + trpc_agent_sdk/code_executors/_constants.py | 56 + .../code_executors/_program_session.py | 167 + trpc_agent_sdk/code_executors/_types.py | 333 ++ .../code_executors/container/__init__.py | 33 + .../container/_container_cli.py | 312 ++ .../container/_container_code_executor.py | 150 + .../container/_container_ws_runtime.py | 953 +++++ .../code_executors/cube/__init__.py | 44 + .../code_executors/cube/_code_executor.py | 274 ++ trpc_agent_sdk/code_executors/cube/_paths.py | 114 + .../code_executors/cube/_runtime.py | 506 +++ .../code_executors/cube/_sandbox.py | 425 +++ .../code_executors/cube/_transfer.py | 198 + trpc_agent_sdk/code_executors/cube/_types.py | 131 + .../code_executors/local/__init__.py | 25 + .../local/_local_program_session.py | 299 ++ .../code_executors/local/_local_ws_runtime.py | 716 ++++ .../local/_unsafe_local_code_executor.py | 212 ++ .../code_executors/utils/__init__.py | 52 + .../code_executors/utils/_code_execution.py | 267 ++ trpc_agent_sdk/code_executors/utils/_files.py | 276 ++ trpc_agent_sdk/code_executors/utils/_meta.py | 271 ++ .../code_executors/utils/_workspace.py | 167 + trpc_agent_sdk/common/__init__.py | 18 + trpc_agent_sdk/common/_compatible.py | 85 + trpc_agent_sdk/common/_constants.py | 6 + trpc_agent_sdk/configs/__init__.py | 18 + trpc_agent_sdk/configs/_model_retry_config.py | 42 + .../configs/_prompt_cache_config.py | 72 + trpc_agent_sdk/configs/_run_config.py | 97 + trpc_agent_sdk/context/__init__.py | 51 + trpc_agent_sdk/context/_agent_context.py | 71 + trpc_agent_sdk/context/_common.py | 66 + trpc_agent_sdk/context/_constants.py | 8 + trpc_agent_sdk/context/_invocation_context.py | 348 ++ trpc_agent_sdk/dsl/__init__.py | 6 + trpc_agent_sdk/dsl/graph/__init__.py | 172 + trpc_agent_sdk/dsl/graph/_callbacks.py | 239 ++ trpc_agent_sdk/dsl/graph/_constants.py | 194 + trpc_agent_sdk/dsl/graph/_event_writer.py | 389 ++ trpc_agent_sdk/dsl/graph/_events/__init__.py | 44 + trpc_agent_sdk/dsl/graph/_events/_builder.py | 518 +++ .../dsl/graph/_events/_constants.py | 78 + trpc_agent_sdk/dsl/graph/_events/_helpers.py | 154 + trpc_agent_sdk/dsl/graph/_events/_metadata.py | 252 ++ trpc_agent_sdk/dsl/graph/_graph_agent.py | 514 +++ trpc_agent_sdk/dsl/graph/_interrupt.py | 28 + trpc_agent_sdk/dsl/graph/_memory_saver.py | 514 +++ .../dsl/graph/_node_action/__init__.py | 26 + .../dsl/graph/_node_action/_agent.py | 364 ++ .../dsl/graph/_node_action/_base.py | 75 + .../dsl/graph/_node_action/_code.py | 63 + .../dsl/graph/_node_action/_knowledge.py | 117 + trpc_agent_sdk/dsl/graph/_node_action/_llm.py | 519 +++ trpc_agent_sdk/dsl/graph/_node_action/_mcp.py | 98 + trpc_agent_sdk/dsl/graph/_node_config.py | 53 + trpc_agent_sdk/dsl/graph/_state.py | 420 +++ trpc_agent_sdk/dsl/graph/_state_graph.py | 817 +++++ trpc_agent_sdk/dsl/graph/_state_mapper.py | 254 ++ trpc_agent_sdk/evaluation/__init__.py | 396 ++ trpc_agent_sdk/evaluation/_agent_evaluator.py | 960 +++++ trpc_agent_sdk/evaluation/_agent_optimizer.py | 614 ++++ trpc_agent_sdk/evaluation/_base_optimizer.py | 123 + trpc_agent_sdk/evaluation/_common.py | 39 + .../evaluation/_criterion_registry.py | 111 + trpc_agent_sdk/evaluation/_eval_callbacks.py | 416 +++ trpc_agent_sdk/evaluation/_eval_case.py | 289 ++ trpc_agent_sdk/evaluation/_eval_config.py | 109 + trpc_agent_sdk/evaluation/_eval_criterion.py | 469 +++ trpc_agent_sdk/evaluation/_eval_metrics.py | 131 + trpc_agent_sdk/evaluation/_eval_pass.py | 53 + trpc_agent_sdk/evaluation/_eval_result.py | 313 ++ .../evaluation/_eval_service_base.py | 225 ++ .../evaluation/_eval_session_service.py | 99 + trpc_agent_sdk/evaluation/_eval_set.py | 61 + .../_eval_set_results_manager_base.py | 81 + .../_eval_set_results_manager_utils.py | 258 ++ .../evaluation/_eval_sets_manager_base.py | 126 + .../evaluation/_eval_sets_manager_utils.py | 102 + trpc_agent_sdk/evaluation/_evaluator_base.py | 74 + .../evaluation/_evaluator_registry.py | 121 + .../evaluation/_final_response_evaluator.py | 95 + .../_in_memory_eval_sets_manager.py | 221 ++ trpc_agent_sdk/evaluation/_llm_criterion.py | 248 ++ trpc_agent_sdk/evaluation/_llm_evaluator.py | 253 ++ trpc_agent_sdk/evaluation/_llm_judge.py | 1353 +++++++ .../evaluation/_local_eval_service.py | 854 +++++ .../_local_eval_set_results_manager.py | 161 + .../evaluation/_local_eval_sets_manager.py | 251 ++ trpc_agent_sdk/evaluation/_optimize_config.py | 257 ++ .../evaluation/_optimize_evaluator_call.py | 136 + .../evaluation/_optimize_gepa_adapter.py | 794 ++++ .../evaluation/_optimize_gepa_callback.py | 381 ++ .../evaluation/_optimize_gepa_reflective.py | 612 ++++ .../evaluation/_optimize_metric_info.py | 534 +++ .../evaluation/_optimize_model_callable.py | 309 ++ .../evaluation/_optimize_model_options.py | 45 + .../evaluation/_optimize_registrations.py | 22 + .../evaluation/_optimize_registry.py | 41 + .../evaluation/_optimize_reporter.py | 1001 ++++++ trpc_agent_sdk/evaluation/_optimize_result.py | 361 ++ .../evaluation/_remote_eval_service.py | 475 +++ trpc_agent_sdk/evaluation/_rouge_evaluator.py | 173 + .../evaluation/_static_user_simulator.py | 83 + trpc_agent_sdk/evaluation/_target_prompt.py | 243 ++ .../evaluation/_trajectory_evaluator.py | 148 + .../evaluation/_user_simulator_base.py | 119 + .../evaluation/_user_simulator_provider.py | 71 + trpc_agent_sdk/evaluation/_utils.py | 367 ++ trpc_agent_sdk/events/__init__.py | 27 + .../events/_agent_cancelled_event.py | 60 + trpc_agent_sdk/events/_cache_analyzer.py | 92 + trpc_agent_sdk/events/_event.py | 284 ++ trpc_agent_sdk/events/_event_translator.py | 67 + trpc_agent_sdk/events/_long_running_event.py | 37 + trpc_agent_sdk/events/_utils.py | 49 + trpc_agent_sdk/exceptions/__init__.py | 24 + trpc_agent_sdk/exceptions/_exceptions.py | 60 + trpc_agent_sdk/filter/__init__.py | 58 + trpc_agent_sdk/filter/_base_filter.py | 239 ++ trpc_agent_sdk/filter/_filter_runner.py | 128 + trpc_agent_sdk/filter/_registry.py | 175 + trpc_agent_sdk/filter/_run_filter.py | 104 + trpc_agent_sdk/knowledge/__init__.py | 22 + trpc_agent_sdk/knowledge/_filter_expr.py | 106 + trpc_agent_sdk/knowledge/_knowledge.py | 91 + trpc_agent_sdk/log/__init__.py | 44 + trpc_agent_sdk/log/_base_logger.py | 124 + trpc_agent_sdk/log/_default_logger.py | 228 ++ trpc_agent_sdk/log/_logger.py | 189 + trpc_agent_sdk/memory/__init__.py | 36 + .../memory/_in_memory_memory_service.py | 214 ++ .../memory/_redis_memory_service.py | 116 + trpc_agent_sdk/memory/_sql_memory_service.py | 342 ++ trpc_agent_sdk/memory/_utils.py | 44 + trpc_agent_sdk/memory/mem0_memory_service.py | 438 +++ .../memory/mempalace_memory_service.py | 441 +++ trpc_agent_sdk/models/__init__.py | 98 + trpc_agent_sdk/models/_anthropic_model.py | 813 +++++ trpc_agent_sdk/models/_constants.py | 93 + trpc_agent_sdk/models/_httpx_client.py | 144 + trpc_agent_sdk/models/_litellm_model.py | 580 +++ trpc_agent_sdk/models/_llm_model.py | 217 ++ trpc_agent_sdk/models/_llm_request.py | 125 + trpc_agent_sdk/models/_llm_response.py | 105 + trpc_agent_sdk/models/_openai_model.py | 1784 +++++++++ trpc_agent_sdk/models/_registry.py | 136 + trpc_agent_sdk/models/_retry.py | 207 ++ .../models/openai_adapter/__init__.py | 36 + trpc_agent_sdk/models/openai_adapter/_base.py | 183 + .../models/openai_adapter/_deepseek.py | 81 + .../models/openai_adapter/_hunyuan.py | 84 + trpc_agent_sdk/models/tool_prompt/__init__.py | 26 + trpc_agent_sdk/models/tool_prompt/_base.py | 52 + trpc_agent_sdk/models/tool_prompt/_factory.py | 85 + trpc_agent_sdk/models/tool_prompt/_json.py | 223 ++ trpc_agent_sdk/models/tool_prompt/_xml.py | 224 ++ trpc_agent_sdk/planners/__init__.py | 30 + trpc_agent_sdk/planners/_built_in_planner.py | 127 + .../planners/_plan_re_act_planner.py | 337 ++ .../planners/_planning_processor.py | 176 + trpc_agent_sdk/runners.py | 842 +++++ trpc_agent_sdk/server/__init__.py | 10 + trpc_agent_sdk/server/a2a/README.md | 172 + trpc_agent_sdk/server/a2a/__init__.py | 25 + .../server/a2a/_agent_card_builder.py | 485 +++ trpc_agent_sdk/server/a2a/_agent_service.py | 150 + trpc_agent_sdk/server/a2a/_constants.py | 102 + .../server/a2a/_remote_a2a_agent.py | 436 +++ trpc_agent_sdk/server/a2a/_utils.py | 53 + .../server/a2a/converters/__init__.py | 65 + .../server/a2a/converters/_event_converter.py | 733 ++++ .../server/a2a/converters/_part_converter.py | 321 ++ .../a2a/converters/_request_converter.py | 111 + .../server/a2a/executor/__init__.py | 31 + .../a2a/executor/_a2a_agent_executor.py | 337 ++ .../a2a/executor/_task_result_aggregator.py | 72 + trpc_agent_sdk/server/a2a/logs/__init__.py | 31 + trpc_agent_sdk/server/a2a/logs/_log_utils.py | 325 ++ trpc_agent_sdk/server/ag_ui/README.md | 131 + trpc_agent_sdk/server/ag_ui/__init__.py | 26 + trpc_agent_sdk/server/ag_ui/_core/__init__.py | 63 + .../server/ag_ui/_core/_agui_agent.py | 1278 +++++++ .../server/ag_ui/_core/_client_proxy_tool.py | 153 + .../ag_ui/_core/_client_proxy_toolset.py | 89 + .../server/ag_ui/_core/_converters.py | 350 ++ .../server/ag_ui/_core/_endpoint.py | 114 + .../server/ag_ui/_core/_event_translator.py | 737 ++++ .../server/ag_ui/_core/_execution_state.py | 134 + .../server/ag_ui/_core/_feed_back_content.py | 61 + .../server/ag_ui/_core/_http_req.py | 28 + .../server/ag_ui/_core/_session_manager.py | 612 ++++ .../server/ag_ui/_plugin/__init__.py | 24 + .../_plugin/_langgraph_event_translator.py | 65 + .../server/ag_ui/_plugin/_manager.py | 87 + .../server/ag_ui/_plugin/_registry.py | 42 + .../server/ag_ui/_plugin/_service.py | 149 + trpc_agent_sdk/server/ag_ui/_plugin/_utils.py | 80 + trpc_agent_sdk/server/agents/__init__.py | 6 + .../server/agents/claude/__init__.py | 75 + .../server/agents/claude/_claude_agent.py | 1241 +++++++ trpc_agent_sdk/server/agents/claude/_proxy.py | 956 +++++ .../server/agents/claude/_proxy_logger.py | 99 + .../server/agents/claude/_runtime.py | 81 + .../server/agents/claude/_session_config.py | 22 + .../server/agents/claude/_session_manager.py | 451 +++ trpc_agent_sdk/server/agents/claude/_setup.py | 417 +++ trpc_agent_sdk/server/knowledge/__init__.py | 9 + .../server/knowledge/langchain_knowledge.py | 313 ++ .../server/knowledge/tools/__init__.py | 14 + .../tools/langchain_knowledge_searchtool.py | 208 ++ trpc_agent_sdk/server/langfuse/__init__.py | 6 + .../server/langfuse/prompt/__init__.py | 14 + .../server/langfuse/prompt/_manager.py | 97 + .../server/langfuse/tracing/__init__.py | 6 + .../server/langfuse/tracing/opentelemetry.py | 657 ++++ trpc_agent_sdk/server/openclaw/README.md | 403 +++ trpc_agent_sdk/server/openclaw/__init__.py | 5 + trpc_agent_sdk/server/openclaw/_cli.py | 226 ++ trpc_agent_sdk/server/openclaw/_logger.py | 76 + trpc_agent_sdk/server/openclaw/_utils.py | 153 + .../server/openclaw/agent/__init__.py | 18 + .../server/openclaw/agent/_agent.py | 219 ++ .../server/openclaw/agent/_prompts.py | 137 + .../server/openclaw/channels/__init__.py | 22 + .../openclaw/channels/_command_handler.py | 181 + .../server/openclaw/channels/_repair.py | 30 + .../server/openclaw/channels/_telegram.py | 67 + .../server/openclaw/channels/_wecom.py | 96 + trpc_agent_sdk/server/openclaw/claw.py | 774 ++++ .../server/openclaw/config.temp.yaml | 27 + .../server/openclaw/config/__init__.py | 64 + .../server/openclaw/config/_config.py | 363 ++ .../server/openclaw/config/_constants.py | 42 + .../server/openclaw/config_full.temp.yaml | 169 + .../server/openclaw/image/telegram.png | Bin 0 -> 1590508 bytes .../server/openclaw/image/wecom.png | Bin 0 -> 241260 bytes .../server/openclaw/metrics/__init__.py | 14 + .../server/openclaw/metrics/_langfuse.py | 37 + .../server/openclaw/metrics/_metrics.py | 51 + .../server/openclaw/service/__init__.py | 13 + .../server/openclaw/service/_heart_service.py | 148 + .../openclaw/session_memory/__init__.py | 16 + .../session_memory/_claw_memory_service.py | 73 + .../session_memory/_claw_session_service.py | 212 ++ .../session_memory/_claw_summarizer.py | 622 ++++ .../server/openclaw/skill/__init__.py | 22 + trpc_agent_sdk/server/openclaw/skill/_deps.py | 691 ++++ .../server/openclaw/skill/_skill_loader.py | 607 ++++ .../server/openclaw/skill/_skill_parser.py | 239 ++ .../server/openclaw/skill/_skill_tool.py | 124 + .../server/openclaw/skill/_utils.py | 172 + .../server/openclaw/storage/__init__.py | 43 + .../openclaw/storage/_aiofile_storage.py | 218 ++ .../server/openclaw/storage/_constants.py | 26 + .../server/openclaw/storage/_manager.py | 381 ++ .../server/openclaw/storage/_utils.py | 73 + .../server/openclaw/templates/ui.html | 684 ++++ .../server/openclaw/tools/__init__.py | 32 + trpc_agent_sdk/server/openclaw/tools/cron.py | 229 ++ .../server/openclaw/tools/filesystem.py | 488 +++ .../server/openclaw/tools/mcp_tool.py | 91 + .../server/openclaw/tools/message.py | 146 + trpc_agent_sdk/server/openclaw/tools/shell.py | 222 ++ .../server/openclaw/tools/spawn_task.py | 121 + trpc_agent_sdk/server/openclaw/tools/web.py | 464 +++ trpc_agent_sdk/server/openclaw/ui.py | 664 ++++ trpc_agent_sdk/sessions/__init__.py | 87 + .../sessions/_base_session_service.py | 214 ++ trpc_agent_sdk/sessions/_history_record.py | 58 + .../sessions/_in_memory_session_service.py | 555 +++ .../sessions/_redis_session_service.py | 423 +++ trpc_agent_sdk/sessions/_session.py | 138 + .../sessions/_session_summarizer.py | 450 +++ .../sessions/_sql_session_service.py | 835 +++++ .../sessions/_summarizer_checker.py | 176 + .../sessions/_summarizer_manager.py | 179 + trpc_agent_sdk/sessions/_types.py | 53 + trpc_agent_sdk/sessions/_utils.py | 158 + trpc_agent_sdk/skills/__init__.py | 196 + trpc_agent_sdk/skills/_common.py | 491 +++ trpc_agent_sdk/skills/_constants.py | 105 + trpc_agent_sdk/skills/_dynamic_toolset.py | 389 ++ trpc_agent_sdk/skills/_hot_reload.py | 163 + trpc_agent_sdk/skills/_registry.py | 106 + trpc_agent_sdk/skills/_repository.py | 728 ++++ trpc_agent_sdk/skills/_skill_config.py | 60 + trpc_agent_sdk/skills/_skill_profile.py | 130 + trpc_agent_sdk/skills/_state_keys.py | 151 + trpc_agent_sdk/skills/_state_migration.py | 165 + trpc_agent_sdk/skills/_state_order.py | 82 + trpc_agent_sdk/skills/_toolset.py | 182 + trpc_agent_sdk/skills/_types.py | 301 ++ trpc_agent_sdk/skills/_url_root.py | 844 +++++ trpc_agent_sdk/skills/_utils.py | 258 ++ trpc_agent_sdk/skills/hub/__init__.py | 59 + .../skills/hub/_claude_marketplace.py | 103 + trpc_agent_sdk/skills/hub/_clawhub.py | 482 +++ trpc_agent_sdk/skills/hub/_github.py | 391 ++ trpc_agent_sdk/skills/hub/_hermes_index.py | 187 + trpc_agent_sdk/skills/hub/_install.py | 193 + trpc_agent_sdk/skills/hub/_lobehub.py | 160 + trpc_agent_sdk/skills/hub/_skills_sh.py | 465 +++ trpc_agent_sdk/skills/hub/_source.py | 42 + trpc_agent_sdk/skills/hub/_types.py | 77 + trpc_agent_sdk/skills/hub/_well_known.py | 242 ++ trpc_agent_sdk/skills/stager/__init__.py | 23 + trpc_agent_sdk/skills/stager/_base_stager.py | 359 ++ trpc_agent_sdk/skills/stager/_types.py | 45 + trpc_agent_sdk/skills/stager/_utils.py | 18 + trpc_agent_sdk/skills/tools/__init__.py | 42 + trpc_agent_sdk/skills/tools/_common.py | 159 + trpc_agent_sdk/skills/tools/_file_stager.py | 102 + trpc_agent_sdk/skills/tools/_save_artifact.py | 270 ++ trpc_agent_sdk/skills/tools/_skill_exec.py | 833 +++++ trpc_agent_sdk/skills/tools/_skill_list.py | 32 + .../skills/tools/_skill_list_docs.py | 40 + .../skills/tools/_skill_list_tool.py | 37 + trpc_agent_sdk/skills/tools/_skill_load.py | 159 + trpc_agent_sdk/skills/tools/_skill_run.py | 918 +++++ .../skills/tools/_skill_select_docs.py | 116 + .../skills/tools/_skill_select_tools.py | 136 + .../skills/tools/_workspace_exec.py | 523 +++ trpc_agent_sdk/storage/__init__.py | 66 + trpc_agent_sdk/storage/_constants.py | 12 + trpc_agent_sdk/storage/_db.py | 45 + trpc_agent_sdk/storage/_redis.py | 310 ++ trpc_agent_sdk/storage/_sql.py | 333 ++ trpc_agent_sdk/storage/_sql_common.py | 388 ++ trpc_agent_sdk/teams/__init__.py | 18 + trpc_agent_sdk/teams/_team_agent.py | 1106 ++++++ trpc_agent_sdk/teams/core/__init__.py | 42 + .../teams/core/_delegation_signal.py | 125 + .../teams/core/_delegation_tools.py | 65 + .../teams/core/_member_message_filter.py | 93 + trpc_agent_sdk/teams/core/_message_builder.py | 316 ++ trpc_agent_sdk/teams/core/_system_message.py | 101 + .../teams/core/_team_run_context.py | 307 ++ trpc_agent_sdk/telemetry/__init__.py | 38 + trpc_agent_sdk/telemetry/_custom_metrics.py | 215 ++ trpc_agent_sdk/telemetry/_custom_trace.py | 266 ++ trpc_agent_sdk/telemetry/_metrics.py | 234 ++ trpc_agent_sdk/telemetry/_trace.py | 501 +++ trpc_agent_sdk/tools/__init__.py | 218 ++ trpc_agent_sdk/tools/_agent_tool.py | 217 ++ trpc_agent_sdk/tools/_base_tool.py | 237 ++ trpc_agent_sdk/tools/_constants.py | 27 + trpc_agent_sdk/tools/_context_var.py | 30 + trpc_agent_sdk/tools/_default_toolset.py | 123 + trpc_agent_sdk/tools/_function_tool.py | 190 + trpc_agent_sdk/tools/_load_memory_tool.py | 101 + trpc_agent_sdk/tools/_long_running_tool.py | 68 + trpc_agent_sdk/tools/_preload_memory_tool.py | 88 + trpc_agent_sdk/tools/_registry.py | 304 ++ .../tools/_set_model_response_tool.py | 114 + .../tools/_streaming_function_tool.py | 146 + .../tools/_streaming_progress_tool.py | 213 ++ trpc_agent_sdk/tools/_todo_tool.py | 378 ++ trpc_agent_sdk/tools/_tool_adapter.py | 109 + .../tools/_transfer_to_agent_tool.py | 34 + trpc_agent_sdk/tools/_webfetch_tool.py | 705 ++++ trpc_agent_sdk/tools/_websearch_tool.py | 724 ++++ trpc_agent_sdk/tools/file_tools/__init__.py | 44 + trpc_agent_sdk/tools/file_tools/_bash_tool.py | 219 ++ trpc_agent_sdk/tools/file_tools/_edit_tool.py | 464 +++ .../tools/file_tools/_file_toolset.py | 53 + .../tools/file_tools/_file_utils.py | 99 + trpc_agent_sdk/tools/file_tools/_glob_tool.py | 162 + trpc_agent_sdk/tools/file_tools/_grep_tool.py | 227 ++ trpc_agent_sdk/tools/file_tools/_read_tool.py | 183 + .../tools/file_tools/_write_tool.py | 137 + trpc_agent_sdk/tools/goal_tools/__init__.py | 58 + trpc_agent_sdk/tools/goal_tools/_base.py | 73 + .../tools/goal_tools/_goal_create_tool.py | 66 + .../tools/goal_tools/_goal_get_tool.py | 46 + .../tools/goal_tools/_goal_toolset.py | 53 + .../tools/goal_tools/_goal_update_tool.py | 74 + trpc_agent_sdk/tools/goal_tools/_helpers.py | 168 + trpc_agent_sdk/tools/goal_tools/_lock.py | 65 + trpc_agent_sdk/tools/goal_tools/_models.py | 61 + trpc_agent_sdk/tools/goal_tools/_prompt.py | 64 + trpc_agent_sdk/tools/goal_tools/_setup.py | 258 ++ trpc_agent_sdk/tools/goal_tools/_store.py | 70 + trpc_agent_sdk/tools/mcp_tool/__init__.py | 46 + .../tools/mcp_tool/_mcp_session_manager.py | 320 ++ trpc_agent_sdk/tools/mcp_tool/_mcp_tool.py | 243 ++ trpc_agent_sdk/tools/mcp_tool/_mcp_toolset.py | 288 ++ trpc_agent_sdk/tools/mcp_tool/_types.py | 37 + trpc_agent_sdk/tools/mcp_tool/_utils.py | 84 + trpc_agent_sdk/tools/mem0_tool.py | 115 + trpc_agent_sdk/tools/mempalace_tool.py | 527 +++ trpc_agent_sdk/tools/task_tools/__init__.py | 69 + trpc_agent_sdk/tools/task_tools/_base.py | 88 + trpc_agent_sdk/tools/task_tools/_helpers.py | 98 + trpc_agent_sdk/tools/task_tools/_lock.py | 65 + trpc_agent_sdk/tools/task_tools/_models.py | 92 + trpc_agent_sdk/tools/task_tools/_prompt.py | 62 + trpc_agent_sdk/tools/task_tools/_store.py | 161 + .../tools/task_tools/_task_create_tool.py | 98 + .../tools/task_tools/_task_get_tool.py | 55 + .../tools/task_tools/_task_list_tool.py | 57 + .../tools/task_tools/_task_toolset.py | 66 + .../tools/task_tools/_task_update_tool.py | 184 + .../tools/task_tools/_validators.py | 106 + trpc_agent_sdk/tools/utils/__init__.py | 41 + .../utils/_automatic_function_calling.py | 161 + .../tools/utils/_function_parameter_parse.py | 345 ++ trpc_agent_sdk/tools/utils/_tool_utils.py | 159 + trpc_agent_sdk/types/__init__.py | 42 + trpc_agent_sdk/types/_agent_types.py | 75 + trpc_agent_sdk/types/_event_actions.py | 63 + trpc_agent_sdk/types/_instruction.py | 60 + trpc_agent_sdk/types/_memory.py | 40 + trpc_agent_sdk/types/_state.py | 82 + trpc_agent_sdk/types/_ttl.py | 72 + trpc_agent_sdk/types/_usage.py | 33 + trpc_agent_sdk/utils/__init__.py | 34 + trpc_agent_sdk/utils/_context_utils.py | 32 + trpc_agent_sdk/utils/_execute_cmd.py | 88 + trpc_agent_sdk/utils/_hash_key.py | 11 + trpc_agent_sdk/utils/_json_repair.py | 45 + trpc_agent_sdk/utils/_registry_factory.py | 176 + trpc_agent_sdk/utils/_singleton.py | 80 + trpc_agent_sdk/version.py | 12 + 2185 files changed, 356612 insertions(+) create mode 100644 .coveragerc create mode 100644 .flake8 create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/cla.yml create mode 100644 .gitignore create mode 100644 .yapfignore create mode 100644 CHANGELOG.md create mode 100644 CODE-OF-CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 INSTALL.md create mode 100644 INSTALL.zh_CN.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.zh_CN.md create mode 100755 build.sh create mode 100644 build_mac.sh create mode 100755 clean.sh create mode 100644 coverage.sh create mode 100644 dist_pkg.sh create mode 100644 docs/mkdocs/assets/imgs/agent_cancel.png create mode 100644 docs/mkdocs/assets/imgs/architecture.png create mode 100644 docs/mkdocs/assets/imgs/claude_agent_architecture.png create mode 100644 docs/mkdocs/assets/imgs/container0.png create mode 100644 docs/mkdocs/assets/imgs/container1.png create mode 100644 docs/mkdocs/assets/imgs/eval_run.png create mode 100644 docs/mkdocs/assets/imgs/eval_trace.png create mode 100644 docs/mkdocs/assets/imgs/graph_architecture.png create mode 100644 docs/mkdocs/assets/imgs/human_in_the_loop.png create mode 100644 docs/mkdocs/assets/imgs/local0.png create mode 100644 docs/mkdocs/assets/imgs/local1.png create mode 100644 docs/mkdocs/assets/imgs/optimization_quickstart.png create mode 100644 docs/mkdocs/en/a2a.md create mode 100644 docs/mkdocs/en/agui.md create mode 100644 docs/mkdocs/en/cancel.md create mode 100644 docs/mkdocs/en/claude_agent.md create mode 100644 docs/mkdocs/en/code_executor.md create mode 100644 docs/mkdocs/en/custom_agent.md create mode 100644 docs/mkdocs/en/evaluation.md create mode 100644 docs/mkdocs/en/filter.md create mode 100644 docs/mkdocs/en/graph.md create mode 100644 docs/mkdocs/en/human_in_the_loop.md create mode 100644 docs/mkdocs/en/knowledge.md create mode 100644 docs/mkdocs/en/knowledge_custom_components.md create mode 100644 docs/mkdocs/en/knowledge_document_loader.md create mode 100644 docs/mkdocs/en/knowledge_embedder.md create mode 100644 docs/mkdocs/en/knowledge_prompt_template.md create mode 100644 docs/mkdocs/en/knowledge_retrievers.md create mode 100644 docs/mkdocs/en/knowledge_text_splitter.md create mode 100644 docs/mkdocs/en/knowledge_vectorstore.md create mode 100644 docs/mkdocs/en/langgraph_agent.md create mode 100644 docs/mkdocs/en/llm_agent.md create mode 100644 docs/mkdocs/en/memory.md create mode 100644 docs/mkdocs/en/model.md create mode 100644 docs/mkdocs/en/multi_agents.md create mode 100644 docs/mkdocs/en/openclaw.md create mode 100644 docs/mkdocs/en/optimization.md create mode 100644 docs/mkdocs/en/session.md create mode 100644 docs/mkdocs/en/session_redis.md create mode 100644 docs/mkdocs/en/session_sql.md create mode 100644 docs/mkdocs/en/session_summary.md create mode 100644 docs/mkdocs/en/skill.md create mode 100644 docs/mkdocs/en/sub_agent.md create mode 100644 docs/mkdocs/en/team.md create mode 100644 docs/mkdocs/en/tool.md create mode 100644 docs/mkdocs/zh/a2a.md create mode 100644 docs/mkdocs/zh/agui.md create mode 100644 docs/mkdocs/zh/cancel.md create mode 100644 docs/mkdocs/zh/claude_agent.md create mode 100644 docs/mkdocs/zh/code_executor.md create mode 100644 docs/mkdocs/zh/custom_agent.md create mode 100644 docs/mkdocs/zh/evaluation.md create mode 100644 docs/mkdocs/zh/filter.md create mode 100644 docs/mkdocs/zh/graph.md create mode 100644 docs/mkdocs/zh/human_in_the_loop.md create mode 100644 docs/mkdocs/zh/knowledge.md create mode 100644 docs/mkdocs/zh/knowledge_custom_components.md create mode 100644 docs/mkdocs/zh/knowledge_document_loader.md create mode 100644 docs/mkdocs/zh/knowledge_embedder.md create mode 100644 docs/mkdocs/zh/knowledge_prompt_template.md create mode 100644 docs/mkdocs/zh/knowledge_retrievers.md create mode 100644 docs/mkdocs/zh/knowledge_text_splitter.md create mode 100644 docs/mkdocs/zh/knowledge_vectorstore.md create mode 100644 docs/mkdocs/zh/langgraph_agent.md create mode 100644 docs/mkdocs/zh/llm_agent.md create mode 100644 docs/mkdocs/zh/memory.md create mode 100644 docs/mkdocs/zh/model.md create mode 100644 docs/mkdocs/zh/multi_agents.md create mode 100644 docs/mkdocs/zh/openclaw.md create mode 100644 docs/mkdocs/zh/optimization.md create mode 100644 docs/mkdocs/zh/session.md create mode 100644 docs/mkdocs/zh/session_redis.md create mode 100644 docs/mkdocs/zh/session_sql.md create mode 100644 docs/mkdocs/zh/session_summary.md create mode 100644 docs/mkdocs/zh/skill.md create mode 100644 docs/mkdocs/zh/sub_agent.md create mode 100644 docs/mkdocs/zh/team.md create mode 100644 docs/mkdocs/zh/tool.md create mode 100644 docs/superpowers/plans/2026-07-30-eval-optimize-loop-optimization.md create mode 100644 examples/a2a/.env create mode 100644 examples/a2a/README.md create mode 100644 examples/a2a/__init__.py create mode 100644 examples/a2a/agent/__init__.py create mode 100644 examples/a2a/agent/agent.py create mode 100644 examples/a2a/agent/config.py create mode 100644 examples/a2a/agent/prompts.py create mode 100644 examples/a2a/agent/tools.py create mode 100644 examples/a2a/run_server.py create mode 100644 examples/a2a/test_a2a.py create mode 100644 examples/a2a_with_cancel/.env create mode 100644 examples/a2a_with_cancel/README.md create mode 100644 examples/a2a_with_cancel/__init__.py create mode 100644 examples/a2a_with_cancel/agent/__init__.py create mode 100644 examples/a2a_with_cancel/agent/agent.py create mode 100644 examples/a2a_with_cancel/agent/config.py create mode 100644 examples/a2a_with_cancel/agent/prompts.py create mode 100644 examples/a2a_with_cancel/agent/tools.py create mode 100644 examples/a2a_with_cancel/run_server.py create mode 100644 examples/a2a_with_cancel/test_a2a_cancel.py create mode 100644 examples/agent_tools/.env create mode 100644 examples/agent_tools/README.md create mode 100644 examples/agent_tools/agent/__init__.py create mode 100644 examples/agent_tools/agent/agent.py create mode 100644 examples/agent_tools/agent/config.py create mode 100644 examples/agent_tools/agent/prompts.py create mode 100644 examples/agent_tools/agent/tools.py create mode 100644 examples/agent_tools/run_agent.py create mode 100644 examples/agui/.env create mode 100644 examples/agui/README.md create mode 100644 examples/agui/_agui_runner.py create mode 100644 examples/agui/agent/__init__.py create mode 100644 examples/agui/agent/agent.py create mode 100644 examples/agui/agent/config.py create mode 100644 examples/agui/agent/prompts.py create mode 100644 examples/agui/agent/tools.py create mode 100644 examples/agui/client_js/main.js create mode 100644 examples/agui/client_js/package.json create mode 100644 examples/agui/run_server.py create mode 100644 examples/agui_with_cancel/.env create mode 100644 examples/agui_with_cancel/README.md create mode 100644 examples/agui_with_cancel/_agui_runner.py create mode 100644 examples/agui_with_cancel/agent/__init__.py create mode 100644 examples/agui_with_cancel/agent/agent.py create mode 100644 examples/agui_with_cancel/agent/config.py create mode 100644 examples/agui_with_cancel/agent/prompts.py create mode 100644 examples/agui_with_cancel/agent/tools.py create mode 100644 examples/agui_with_cancel/client_js/main.js create mode 100644 examples/agui_with_cancel/client_js/package.json create mode 100644 examples/agui_with_cancel/run_server.py create mode 100644 examples/claude_agent/.env create mode 100644 examples/claude_agent/README.md create mode 100644 examples/claude_agent/agent/__init__.py create mode 100644 examples/claude_agent/agent/agent.py create mode 100644 examples/claude_agent/agent/config.py create mode 100644 examples/claude_agent/agent/prompts.py create mode 100644 examples/claude_agent/agent/tools.py create mode 100644 examples/claude_agent/run_agent.py create mode 100644 examples/claude_agent_with_cancel/.env create mode 100644 examples/claude_agent_with_cancel/README.md create mode 100644 examples/claude_agent_with_cancel/agent/__init__.py create mode 100644 examples/claude_agent_with_cancel/agent/agent.py create mode 100644 examples/claude_agent_with_cancel/agent/config.py create mode 100644 examples/claude_agent_with_cancel/agent/prompts.py create mode 100644 examples/claude_agent_with_cancel/agent/tools.py create mode 100644 examples/claude_agent_with_cancel/run_agent.py create mode 100644 examples/claude_agent_with_code_writer/.env create mode 100644 examples/claude_agent_with_code_writer/README.md create mode 100644 examples/claude_agent_with_code_writer/agent/__init__.py create mode 100644 examples/claude_agent_with_code_writer/agent/agent.py create mode 100644 examples/claude_agent_with_code_writer/agent/config.py create mode 100644 examples/claude_agent_with_code_writer/agent/prompts.py create mode 100644 examples/claude_agent_with_code_writer/run_agent.py create mode 100644 examples/claude_agent_with_skills/.env create mode 100644 examples/claude_agent_with_skills/README.md create mode 100644 examples/claude_agent_with_skills/agent/__init__.py create mode 100644 examples/claude_agent_with_skills/agent/agent.py create mode 100644 examples/claude_agent_with_skills/agent/config.py create mode 100644 examples/claude_agent_with_skills/agent/prompts.py create mode 100644 examples/claude_agent_with_skills/agent/tools.py create mode 100644 examples/claude_agent_with_skills/run_agent.py create mode 100644 examples/claude_agent_with_streaming_tool/.env create mode 100644 examples/claude_agent_with_streaming_tool/README.md create mode 100644 examples/claude_agent_with_streaming_tool/agent/__init__.py create mode 100644 examples/claude_agent_with_streaming_tool/agent/agent.py create mode 100644 examples/claude_agent_with_streaming_tool/agent/config.py create mode 100644 examples/claude_agent_with_streaming_tool/agent/prompts.py create mode 100644 examples/claude_agent_with_streaming_tool/agent/tools.py create mode 100644 examples/claude_agent_with_streaming_tool/run_agent.py create mode 100644 examples/claude_agent_with_travel_planner/.env create mode 100644 examples/claude_agent_with_travel_planner/README.md create mode 100644 examples/claude_agent_with_travel_planner/agent/__init__.py create mode 100644 examples/claude_agent_with_travel_planner/agent/agent.py create mode 100644 examples/claude_agent_with_travel_planner/agent/config.py create mode 100644 examples/claude_agent_with_travel_planner/agent/prompts.py create mode 100644 examples/claude_agent_with_travel_planner/agent/tools.py create mode 100644 examples/claude_agent_with_travel_planner/run_agent.py create mode 100644 examples/code_executors/.env create mode 100644 examples/code_executors/README.md create mode 100644 examples/code_executors/agent/__init__.py create mode 100644 examples/code_executors/agent/agent.py create mode 100644 examples/code_executors/agent/config.py create mode 100644 examples/code_executors/agent/prompts.py create mode 100644 examples/code_executors/agent/tools.py create mode 100644 examples/code_executors/cube_demo.py create mode 100644 examples/code_executors/run_agent.py create mode 100644 examples/dsl/README.md create mode 100644 examples/dsl/classifier_mcp/.env create mode 100644 examples/dsl/classifier_mcp/README.md create mode 100644 examples/dsl/classifier_mcp/agent/__init__.py create mode 100644 examples/dsl/classifier_mcp/agent/agent.py create mode 100644 examples/dsl/classifier_mcp/agent/callbacks.py create mode 100644 examples/dsl/classifier_mcp/agent/config.py create mode 100644 examples/dsl/classifier_mcp/agent/nodes.py create mode 100644 examples/dsl/classifier_mcp/agent/prompts.py create mode 100644 examples/dsl/classifier_mcp/agent/state.py create mode 100644 examples/dsl/classifier_mcp/agent/tools.py create mode 100644 examples/dsl/classifier_mcp/requirements.txt create mode 100644 examples/dsl/classifier_mcp/run_agent.py create mode 100644 examples/dsl/classifier_mcp/workflow.json create mode 100644 examples/dynamic_subagent/.env create mode 100644 examples/dynamic_subagent/README.md create mode 100644 examples/dynamic_subagent/agent/__init__.py create mode 100644 examples/dynamic_subagent/agent/agent.py create mode 100644 examples/dynamic_subagent/agent/config.py create mode 100644 examples/dynamic_subagent/agent/prompts.py create mode 100644 examples/dynamic_subagent/agent/tools.py create mode 100644 examples/dynamic_subagent/run_agent.py create mode 100644 examples/evaluation/.env create mode 100644 examples/evaluation/callbacks/.env create mode 100644 examples/evaluation/callbacks/README.md create mode 100644 examples/evaluation/callbacks/agent/__init__.py create mode 100644 examples/evaluation/callbacks/agent/agent.py create mode 100644 examples/evaluation/callbacks/agent/callbacks_example.evalset.json create mode 100644 examples/evaluation/callbacks/agent/config.py create mode 100644 examples/evaluation/callbacks/agent/test_config.json create mode 100644 examples/evaluation/callbacks/test_callbacks.py create mode 100644 examples/evaluation/context_messages/.env create mode 100644 examples/evaluation/context_messages/README.md create mode 100644 examples/evaluation/context_messages/agent/__init__.py create mode 100644 examples/evaluation/context_messages/agent/agent.py create mode 100644 examples/evaluation/context_messages/agent/config.py create mode 100644 examples/evaluation/context_messages/agent/context_example.evalset.json create mode 100644 examples/evaluation/context_messages/agent/test_config.json create mode 100644 examples/evaluation/context_messages/test_context_messages.py create mode 100644 examples/evaluation/custom_runner/.env create mode 100644 examples/evaluation/custom_runner/README.md create mode 100644 examples/evaluation/custom_runner/agent/__init__.py create mode 100644 examples/evaluation/custom_runner/agent/agent.py create mode 100644 examples/evaluation/custom_runner/agent/config.py create mode 100644 examples/evaluation/custom_runner/agent/custom_runner_example.evalset.json create mode 100644 examples/evaluation/custom_runner/agent/test_config.json create mode 100644 examples/evaluation/custom_runner/test_custom_runner.py create mode 100644 examples/evaluation/llm_final_response/.env create mode 100644 examples/evaluation/llm_final_response/README.md create mode 100644 examples/evaluation/llm_final_response/agent/__init__.py create mode 100644 examples/evaluation/llm_final_response/agent/agent.py create mode 100644 examples/evaluation/llm_final_response/agent/config.py create mode 100644 examples/evaluation/llm_final_response/agent/llm_final_response.evalset.json create mode 100644 examples/evaluation/llm_final_response/agent/test_config.json create mode 100644 examples/evaluation/llm_final_response/test_llm_final_response.py create mode 100644 examples/evaluation/llm_judge_tools/.env create mode 100644 examples/evaluation/llm_judge_tools/README.md create mode 100644 examples/evaluation/llm_judge_tools/agent/__init__.py create mode 100644 examples/evaluation/llm_judge_tools/agent/agent.py create mode 100644 examples/evaluation/llm_judge_tools/agent/config.py create mode 100644 examples/evaluation/llm_judge_tools/agent/judge_tools.evalset.json create mode 100644 examples/evaluation/llm_judge_tools/agent/test_config.json create mode 100644 examples/evaluation/llm_judge_tools/test_llm_judge_tools.py create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/.env create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/README.md create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/agent/__init__.py create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/agent/agent.py create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/agent/config.py create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/agent/llm_rubric_knowledge_recall.evalset.json create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/agent/test_config.json create mode 100644 examples/evaluation/llm_rubric_knowledge_recall/test_llm_rubric_knowledge_recall.py create mode 100644 examples/evaluation/llm_rubric_response/.env create mode 100644 examples/evaluation/llm_rubric_response/README.md create mode 100644 examples/evaluation/llm_rubric_response/agent/__init__.py create mode 100644 examples/evaluation/llm_rubric_response/agent/agent.py create mode 100644 examples/evaluation/llm_rubric_response/agent/config.py create mode 100644 examples/evaluation/llm_rubric_response/agent/llm_rubric_response.evalset.json create mode 100644 examples/evaluation/llm_rubric_response/agent/test_config.json create mode 100644 examples/evaluation/llm_rubric_response/test_llm_rubric_response.py create mode 100644 examples/evaluation/pass_at_k/.env create mode 100644 examples/evaluation/pass_at_k/README.md create mode 100644 examples/evaluation/pass_at_k/agent/__init__.py create mode 100644 examples/evaluation/pass_at_k/agent/agent.py create mode 100644 examples/evaluation/pass_at_k/agent/config.py create mode 100644 examples/evaluation/pass_at_k/agent/test_config.json create mode 100644 examples/evaluation/pass_at_k/agent/weather_agent.evalset.json create mode 100644 examples/evaluation/pass_at_k/test_pass_at_k.py create mode 100644 examples/evaluation/quickstart/.env create mode 100644 examples/evaluation/quickstart/README.md create mode 100644 examples/evaluation/quickstart/agent/__init__.py create mode 100644 examples/evaluation/quickstart/agent/agent.py create mode 100644 examples/evaluation/quickstart/agent/config.py create mode 100644 examples/evaluation/quickstart/agent/test_config.json create mode 100644 examples/evaluation/quickstart/agent/weather_agent.evalset.json create mode 100644 examples/evaluation/quickstart/test_quickstart.py create mode 100644 examples/evaluation/trace_mode/.env create mode 100644 examples/evaluation/trace_mode/README.md create mode 100644 examples/evaluation/trace_mode/agent/__init__.py create mode 100644 examples/evaluation/trace_mode/agent/agent.py create mode 100644 examples/evaluation/trace_mode/agent/config.py create mode 100644 examples/evaluation/trace_mode/agent/test_config.json create mode 100644 examples/evaluation/trace_mode/agent/trace_example.evalset.json create mode 100644 examples/evaluation/trace_mode/test_trace_mode.py create mode 100644 examples/evaluation/webui/.env create mode 100644 examples/evaluation/webui/README.md create mode 100644 examples/evaluation/webui/agent/__init__.py create mode 100644 examples/evaluation/webui/agent/agent.evalset.json create mode 100644 examples/evaluation/webui/agent/agent.py create mode 100644 examples/evaluation/webui/agent/config.py create mode 100644 examples/evaluation/webui/agent/prompts.py create mode 100644 examples/evaluation/webui/agent/test_config.json create mode 100644 examples/evaluation/webui/agent/tools.py create mode 100644 examples/evaluation/webui/test_book_finder.py create mode 100644 examples/fastapi_server/.env create mode 100644 examples/fastapi_server/README.md create mode 100644 examples/fastapi_server/__init__.py create mode 100644 examples/fastapi_server/_app.py create mode 100644 examples/fastapi_server/_runner_manager.py create mode 100644 examples/fastapi_server/_schemas.py create mode 100644 examples/fastapi_server/agent/__init__.py create mode 100644 examples/fastapi_server/agent/agent.py create mode 100644 examples/fastapi_server/agent/config.py create mode 100644 examples/fastapi_server/agent/prompts.py create mode 100644 examples/fastapi_server/agent/tools.py create mode 100644 examples/fastapi_server/run_server.py create mode 100644 examples/fastapi_server/test/client.py create mode 100644 examples/fastapi_server/test/curl_cli.sh create mode 100644 examples/file_tools/.env create mode 100644 examples/file_tools/README.md create mode 100644 examples/file_tools/agent/__init__.py create mode 100644 examples/file_tools/agent/agent.py create mode 100644 examples/file_tools/agent/config.py create mode 100644 examples/file_tools/agent/prompts.py create mode 100644 examples/file_tools/run_agent.py create mode 100644 examples/filter_with_agent/.env create mode 100644 examples/filter_with_agent/README.md create mode 100644 examples/filter_with_agent/agent/__init__.py create mode 100644 examples/filter_with_agent/agent/agent.py create mode 100644 examples/filter_with_agent/agent/config.py create mode 100644 examples/filter_with_agent/agent/filter.py create mode 100644 examples/filter_with_agent/agent/prompts.py create mode 100644 examples/filter_with_agent/agent/tools.py create mode 100644 examples/filter_with_agent/run_agent.py create mode 100644 examples/filter_with_model/.env create mode 100644 examples/filter_with_model/README.md create mode 100644 examples/filter_with_model/agent/__init__.py create mode 100644 examples/filter_with_model/agent/agent.py create mode 100644 examples/filter_with_model/agent/config.py create mode 100644 examples/filter_with_model/agent/filter.py create mode 100644 examples/filter_with_model/agent/prompts.py create mode 100644 examples/filter_with_model/agent/tools.py create mode 100644 examples/filter_with_model/run_agent.py create mode 100644 examples/filter_with_tool/.env create mode 100644 examples/filter_with_tool/README.md create mode 100644 examples/filter_with_tool/agent/__init__.py create mode 100644 examples/filter_with_tool/agent/agent.py create mode 100644 examples/filter_with_tool/agent/config.py create mode 100644 examples/filter_with_tool/agent/filter.py create mode 100644 examples/filter_with_tool/agent/prompts.py create mode 100644 examples/filter_with_tool/agent/tools.py create mode 100644 examples/filter_with_tool/run_agent.py create mode 100644 examples/function_tools/.env create mode 100644 examples/function_tools/README.md create mode 100644 examples/function_tools/agent/__init__.py create mode 100644 examples/function_tools/agent/agent.py create mode 100644 examples/function_tools/agent/config.py create mode 100644 examples/function_tools/agent/prompts.py create mode 100644 examples/function_tools/agent/tools.py create mode 100644 examples/function_tools/run_agent.py create mode 100644 examples/goal_tools/.env create mode 100644 examples/goal_tools/README.md create mode 100644 examples/goal_tools/agent/__init__.py create mode 100644 examples/goal_tools/agent/agent.py create mode 100644 examples/goal_tools/agent/config.py create mode 100644 examples/goal_tools/agent/prompts.py create mode 100644 examples/goal_tools/run_agent.py create mode 100644 examples/graph/.env create mode 100644 examples/graph/README.md create mode 100644 examples/graph/agent/__init__.py create mode 100644 examples/graph/agent/agent.py create mode 100644 examples/graph/agent/callbacks.py create mode 100644 examples/graph/agent/config.py create mode 100644 examples/graph/agent/nodes.py create mode 100644 examples/graph/agent/prompts.py create mode 100644 examples/graph/agent/state.py create mode 100644 examples/graph/agent/tools.py create mode 100644 examples/graph/mcp_server.py create mode 100644 examples/graph/run_agent.py create mode 100644 examples/graph_multi_turns/.env create mode 100644 examples/graph_multi_turns/README.md create mode 100644 examples/graph_multi_turns/agent/__init__.py create mode 100644 examples/graph_multi_turns/agent/agent.py create mode 100644 examples/graph_multi_turns/agent/callbacks.py create mode 100644 examples/graph_multi_turns/agent/config.py create mode 100644 examples/graph_multi_turns/agent/nodes.py create mode 100644 examples/graph_multi_turns/agent/prompts.py create mode 100644 examples/graph_multi_turns/agent/state.py create mode 100644 examples/graph_multi_turns/agent/tools.py create mode 100644 examples/graph_multi_turns/run_agent.py create mode 100644 examples/graph_with_interrupt/.env create mode 100644 examples/graph_with_interrupt/README.md create mode 100644 examples/graph_with_interrupt/agent/__init__.py create mode 100644 examples/graph_with_interrupt/agent/agent.py create mode 100644 examples/graph_with_interrupt/agent/callbacks.py create mode 100644 examples/graph_with_interrupt/agent/config.py create mode 100644 examples/graph_with_interrupt/agent/nodes.py create mode 100644 examples/graph_with_interrupt/agent/prompts.py create mode 100644 examples/graph_with_interrupt/agent/state.py create mode 100644 examples/graph_with_interrupt/agent/tools.py create mode 100644 examples/graph_with_interrupt/run_agent.py create mode 100644 examples/knowledge_with_custom_components/.env create mode 100644 examples/knowledge_with_custom_components/README.md create mode 100644 examples/knowledge_with_custom_components/agent/__init__.py create mode 100644 examples/knowledge_with_custom_components/agent/agent.py create mode 100644 examples/knowledge_with_custom_components/agent/config.py create mode 100644 examples/knowledge_with_custom_components/agent/prompts.py create mode 100644 examples/knowledge_with_custom_components/agent/tools.py create mode 100644 examples/knowledge_with_custom_components/run_agent.py create mode 100644 examples/knowledge_with_documentloader/.env create mode 100644 examples/knowledge_with_documentloader/README.md create mode 100644 examples/knowledge_with_documentloader/agent/__init__.py create mode 100644 examples/knowledge_with_documentloader/agent/agent.py create mode 100644 examples/knowledge_with_documentloader/agent/config.py create mode 100644 examples/knowledge_with_documentloader/agent/prompts.py create mode 100644 examples/knowledge_with_documentloader/agent/tools.py create mode 100644 examples/knowledge_with_documentloader/run_agent.py create mode 100644 examples/knowledge_with_prompt_template/.env create mode 100644 examples/knowledge_with_prompt_template/README.md create mode 100644 examples/knowledge_with_prompt_template/agent/__init__.py create mode 100644 examples/knowledge_with_prompt_template/agent/agent.py create mode 100644 examples/knowledge_with_prompt_template/agent/config.py create mode 100644 examples/knowledge_with_prompt_template/agent/prompts.py create mode 100644 examples/knowledge_with_prompt_template/agent/tools.py create mode 100644 examples/knowledge_with_prompt_template/run_agent.py create mode 100644 examples/knowledge_with_rag_agent/.env create mode 100644 examples/knowledge_with_rag_agent/README.md create mode 100644 examples/knowledge_with_rag_agent/agent/__init__.py create mode 100644 examples/knowledge_with_rag_agent/agent/agent.py create mode 100644 examples/knowledge_with_rag_agent/agent/config.py create mode 100644 examples/knowledge_with_rag_agent/agent/prompts.py create mode 100644 examples/knowledge_with_rag_agent/agent/tools.py create mode 100644 examples/knowledge_with_rag_agent/run_agent.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/.env create mode 100644 examples/knowledge_with_searchtool_rag_agent/README.md create mode 100644 examples/knowledge_with_searchtool_rag_agent/agent/__init__.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/agent/agent.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/agent/config.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/agent/prompts.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/agent/tools.py create mode 100644 examples/knowledge_with_searchtool_rag_agent/run_agent.py create mode 100644 examples/knowledge_with_vectorstore/.env create mode 100644 examples/knowledge_with_vectorstore/README.md create mode 100644 examples/knowledge_with_vectorstore/agent/__init__.py create mode 100644 examples/knowledge_with_vectorstore/agent/agent.py create mode 100644 examples/knowledge_with_vectorstore/agent/config.py create mode 100644 examples/knowledge_with_vectorstore/agent/prompts.py create mode 100644 examples/knowledge_with_vectorstore/agent/tools.py create mode 100644 examples/knowledge_with_vectorstore/run_agent.py create mode 100644 examples/knowledge_with_vectorstore/test.txt create mode 100644 examples/langchain_tools/.env create mode 100644 examples/langchain_tools/README.md create mode 100644 examples/langchain_tools/agent/__init__.py create mode 100644 examples/langchain_tools/agent/agent.py create mode 100644 examples/langchain_tools/agent/config.py create mode 100644 examples/langchain_tools/agent/prompts.py create mode 100644 examples/langchain_tools/agent/tools.py create mode 100644 examples/langchain_tools/run_agent.py create mode 100644 examples/langgraph_agent/.env create mode 100644 examples/langgraph_agent/README.md create mode 100644 examples/langgraph_agent/agent/__init__.py create mode 100644 examples/langgraph_agent/agent/agent.py create mode 100644 examples/langgraph_agent/agent/config.py create mode 100644 examples/langgraph_agent/agent/tools.py create mode 100644 examples/langgraph_agent/run_agent.py create mode 100644 examples/langgraph_agent_with_cancel/.env create mode 100644 examples/langgraph_agent_with_cancel/README.md create mode 100644 examples/langgraph_agent_with_cancel/agent/__init__.py create mode 100644 examples/langgraph_agent_with_cancel/agent/agent.py create mode 100644 examples/langgraph_agent_with_cancel/agent/config.py create mode 100644 examples/langgraph_agent_with_cancel/agent/tools.py create mode 100644 examples/langgraph_agent_with_cancel/run_agent.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/.env create mode 100644 examples/langgraphagent_with_human_in_the_loop/README.md create mode 100644 examples/langgraphagent_with_human_in_the_loop/agent/__init__.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/agent/agent.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/agent/config.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/agent/prompts.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/agent/tools.py create mode 100644 examples/langgraphagent_with_human_in_the_loop/run_agent.py create mode 100644 examples/litellm/.env create mode 100644 examples/litellm/README.md create mode 100644 examples/litellm/agent/__init__.py create mode 100644 examples/litellm/agent/agent.py create mode 100644 examples/litellm/agent/config.py create mode 100644 examples/litellm/agent/prompts.py create mode 100644 examples/litellm/agent/tools.py create mode 100644 examples/litellm/run_agent.py create mode 100644 examples/llm.sh create mode 100644 examples/llmagent/.env create mode 100644 examples/llmagent/README.md create mode 100644 examples/llmagent/agent/__init__.py create mode 100644 examples/llmagent/agent/agent.py create mode 100644 examples/llmagent/agent/config.py create mode 100644 examples/llmagent/agent/prompts.py create mode 100644 examples/llmagent/agent/tools.py create mode 100644 examples/llmagent/run_agent.py create mode 100644 examples/llmagent_with_branch_filtering/.env create mode 100644 examples/llmagent_with_branch_filtering/README.md create mode 100644 examples/llmagent_with_branch_filtering/agent/__init__.py create mode 100644 examples/llmagent_with_branch_filtering/agent/agent.py create mode 100644 examples/llmagent_with_branch_filtering/agent/config.py create mode 100644 examples/llmagent_with_branch_filtering/agent/prompts.py create mode 100644 examples/llmagent_with_branch_filtering/agent/tools.py create mode 100644 examples/llmagent_with_branch_filtering/run_agent.py create mode 100644 examples/llmagent_with_cancel/.env create mode 100644 examples/llmagent_with_cancel/README.md create mode 100644 examples/llmagent_with_cancel/agent/__init__.py create mode 100644 examples/llmagent_with_cancel/agent/agent.py create mode 100644 examples/llmagent_with_cancel/agent/config.py create mode 100644 examples/llmagent_with_cancel/agent/prompts.py create mode 100644 examples/llmagent_with_cancel/agent/tools.py create mode 100644 examples/llmagent_with_cancel/run_agent.py create mode 100644 examples/llmagent_with_custom_agent/.env create mode 100644 examples/llmagent_with_custom_agent/README.md create mode 100644 examples/llmagent_with_custom_agent/agent/__init__.py create mode 100644 examples/llmagent_with_custom_agent/agent/agent.py create mode 100644 examples/llmagent_with_custom_agent/agent/config.py create mode 100644 examples/llmagent_with_custom_agent/agent/prompts.py create mode 100644 examples/llmagent_with_custom_agent/run_agent.py create mode 100644 examples/llmagent_with_custom_prompt/.env create mode 100644 examples/llmagent_with_custom_prompt/README.md create mode 100644 examples/llmagent_with_custom_prompt/agent/__init__.py create mode 100644 examples/llmagent_with_custom_prompt/agent/agent.py create mode 100644 examples/llmagent_with_custom_prompt/agent/config.py create mode 100644 examples/llmagent_with_custom_prompt/agent/prompts.py create mode 100644 examples/llmagent_with_custom_prompt/agent/tools.py create mode 100644 examples/llmagent_with_custom_prompt/run_agent.py create mode 100644 examples/llmagent_with_human_in_the_loop/.env create mode 100644 examples/llmagent_with_human_in_the_loop/README.md create mode 100644 examples/llmagent_with_human_in_the_loop/agent/__init__.py create mode 100644 examples/llmagent_with_human_in_the_loop/agent/agent.py create mode 100644 examples/llmagent_with_human_in_the_loop/agent/config.py create mode 100644 examples/llmagent_with_human_in_the_loop/agent/prompts.py create mode 100644 examples/llmagent_with_human_in_the_loop/agent/tools.py create mode 100644 examples/llmagent_with_human_in_the_loop/run_agent.py create mode 100644 examples/llmagent_with_max_history_messages/.env create mode 100644 examples/llmagent_with_max_history_messages/README.md create mode 100644 examples/llmagent_with_max_history_messages/agent/__init__.py create mode 100644 examples/llmagent_with_max_history_messages/agent/agent.py create mode 100644 examples/llmagent_with_max_history_messages/agent/config.py create mode 100644 examples/llmagent_with_max_history_messages/agent/prompts.py create mode 100644 examples/llmagent_with_max_history_messages/run_agent.py create mode 100644 examples/llmagent_with_model_create_fn/.env create mode 100644 examples/llmagent_with_model_create_fn/README.md create mode 100644 examples/llmagent_with_model_create_fn/agent/__init__.py create mode 100644 examples/llmagent_with_model_create_fn/agent/agent.py create mode 100644 examples/llmagent_with_model_create_fn/agent/config.py create mode 100644 examples/llmagent_with_model_create_fn/agent/prompts.py create mode 100644 examples/llmagent_with_model_create_fn/agent/tools.py create mode 100644 examples/llmagent_with_model_create_fn/run_agent.py create mode 100644 examples/llmagent_with_model_retry/.env create mode 100644 examples/llmagent_with_model_retry/README.md create mode 100644 examples/llmagent_with_model_retry/agent/__init__.py create mode 100644 examples/llmagent_with_model_retry/agent/agent.py create mode 100644 examples/llmagent_with_model_retry/agent/config.py create mode 100644 examples/llmagent_with_model_retry/agent/prompts.py create mode 100644 examples/llmagent_with_model_retry/agent/tools.py create mode 100644 examples/llmagent_with_model_retry/run_agent.py create mode 100644 examples/llmagent_with_parallal_tools/.env create mode 100644 examples/llmagent_with_parallal_tools/README.md create mode 100644 examples/llmagent_with_parallal_tools/agent/__init__.py create mode 100644 examples/llmagent_with_parallal_tools/agent/agent.py create mode 100644 examples/llmagent_with_parallal_tools/agent/config.py create mode 100644 examples/llmagent_with_parallal_tools/agent/prompts.py create mode 100644 examples/llmagent_with_parallal_tools/agent/tools.py create mode 100644 examples/llmagent_with_parallal_tools/run_agent.py create mode 100644 examples/llmagent_with_prompt_cache/.env create mode 100644 examples/llmagent_with_prompt_cache/README.md create mode 100644 examples/llmagent_with_prompt_cache/agent/__init__.py create mode 100644 examples/llmagent_with_prompt_cache/agent/agent.py create mode 100644 examples/llmagent_with_prompt_cache/agent/config.py create mode 100644 examples/llmagent_with_prompt_cache/agent/prompts.py create mode 100644 examples/llmagent_with_prompt_cache/agent/tools.py create mode 100644 examples/llmagent_with_prompt_cache/run_agent.py create mode 100644 examples/llmagent_with_schema/.env create mode 100644 examples/llmagent_with_schema/README.md create mode 100644 examples/llmagent_with_schema/agent/__init__.py create mode 100644 examples/llmagent_with_schema/agent/agent.py create mode 100644 examples/llmagent_with_schema/agent/config.py create mode 100644 examples/llmagent_with_schema/agent/prompts.py create mode 100644 examples/llmagent_with_schema/agent/tools.py create mode 100644 examples/llmagent_with_schema/run_agent.py create mode 100644 examples/llmagent_with_streaming_progress_tool/.env create mode 100644 examples/llmagent_with_streaming_progress_tool/README.md create mode 100644 examples/llmagent_with_streaming_progress_tool/agent/__init__.py create mode 100644 examples/llmagent_with_streaming_progress_tool/agent/agent.py create mode 100644 examples/llmagent_with_streaming_progress_tool/agent/config.py create mode 100644 examples/llmagent_with_streaming_progress_tool/agent/prompts.py create mode 100644 examples/llmagent_with_streaming_progress_tool/agent/tools.py create mode 100644 examples/llmagent_with_streaming_progress_tool/run_agent.py create mode 100644 examples/llmagent_with_streaming_progress_tool/verify.py create mode 100644 examples/llmagent_with_streaming_tool_complex/.env create mode 100644 examples/llmagent_with_streaming_tool_complex/README.md create mode 100644 examples/llmagent_with_streaming_tool_complex/agent/__init__.py create mode 100644 examples/llmagent_with_streaming_tool_complex/agent/agent.py create mode 100644 examples/llmagent_with_streaming_tool_complex/agent/config.py create mode 100644 examples/llmagent_with_streaming_tool_complex/agent/prompts.py create mode 100644 examples/llmagent_with_streaming_tool_complex/agent/tools.py create mode 100644 examples/llmagent_with_streaming_tool_complex/run_agent.py create mode 100644 examples/llmagent_with_streaming_tool_simple/.env create mode 100644 examples/llmagent_with_streaming_tool_simple/README.md create mode 100644 examples/llmagent_with_streaming_tool_simple/agent/__init__.py create mode 100644 examples/llmagent_with_streaming_tool_simple/agent/agent.py create mode 100644 examples/llmagent_with_streaming_tool_simple/agent/config.py create mode 100644 examples/llmagent_with_streaming_tool_simple/agent/prompts.py create mode 100644 examples/llmagent_with_streaming_tool_simple/agent/tools.py create mode 100644 examples/llmagent_with_streaming_tool_simple/run_agent.py create mode 100644 examples/llmagent_with_thinking/.env create mode 100644 examples/llmagent_with_thinking/README.md create mode 100644 examples/llmagent_with_thinking/agent/__init__.py create mode 100644 examples/llmagent_with_thinking/agent/agent.py create mode 100644 examples/llmagent_with_thinking/agent/config.py create mode 100644 examples/llmagent_with_thinking/agent/prompts.py create mode 100644 examples/llmagent_with_thinking/agent/tools.py create mode 100644 examples/llmagent_with_thinking/run_agent.py create mode 100644 examples/llmagent_with_timeline_filtering/.env create mode 100644 examples/llmagent_with_timeline_filtering/README.md create mode 100644 examples/llmagent_with_timeline_filtering/agent/__init__.py create mode 100644 examples/llmagent_with_timeline_filtering/agent/agent.py create mode 100644 examples/llmagent_with_timeline_filtering/agent/config.py create mode 100644 examples/llmagent_with_timeline_filtering/agent/prompts.py create mode 100644 examples/llmagent_with_timeline_filtering/run_agent.py create mode 100644 examples/llmagent_with_tool_prompt/.env create mode 100644 examples/llmagent_with_tool_prompt/README.md create mode 100644 examples/llmagent_with_tool_prompt/agent/__init__.py create mode 100644 examples/llmagent_with_tool_prompt/agent/agent.py create mode 100644 examples/llmagent_with_tool_prompt/agent/config.py create mode 100644 examples/llmagent_with_tool_prompt/agent/prompts.py create mode 100644 examples/llmagent_with_tool_prompt/agent/tools.py create mode 100644 examples/llmagent_with_tool_prompt/run_agent.py create mode 100644 examples/llmagent_with_user_history/.env create mode 100644 examples/llmagent_with_user_history/README.md create mode 100644 examples/llmagent_with_user_history/agent/__init__.py create mode 100644 examples/llmagent_with_user_history/agent/agent.py create mode 100644 examples/llmagent_with_user_history/agent/config.py create mode 100644 examples/llmagent_with_user_history/agent/prompts.py create mode 100644 examples/llmagent_with_user_history/agent/tools.py create mode 100644 examples/llmagent_with_user_history/run_agent.py create mode 100644 examples/mcp_tools/.env create mode 100644 examples/mcp_tools/README.md create mode 100644 examples/mcp_tools/agent/__init__.py create mode 100644 examples/mcp_tools/agent/agent.py create mode 100644 examples/mcp_tools/agent/config.py create mode 100644 examples/mcp_tools/agent/prompts.py create mode 100644 examples/mcp_tools/agent/tools.py create mode 100644 examples/mcp_tools/mcp_server.py create mode 100644 examples/mcp_tools/run_agent.py create mode 100644 examples/mem0_tools/.env create mode 100644 examples/mem0_tools/README.md create mode 100644 examples/mem0_tools/agent/__init__.py create mode 100644 examples/mem0_tools/agent/agent.py create mode 100644 examples/mem0_tools/agent/config.py create mode 100644 examples/mem0_tools/agent/prompts.py create mode 100644 examples/mem0_tools/agent/tools.py create mode 100644 examples/mem0_tools/images/mem0_ai.png create mode 100644 examples/mem0_tools/images/mem0_plat.png create mode 100644 examples/mem0_tools/images/mem0_result.png create mode 100644 examples/mem0_tools/images/qdrant_dashboard.png create mode 100644 examples/mem0_tools/images/qdrant_mem.png create mode 100644 examples/mem0_tools/run_agent.py create mode 100644 examples/memory_service_with_in_memory/.env create mode 100644 examples/memory_service_with_in_memory/README.md create mode 100644 examples/memory_service_with_in_memory/agent/__init__.py create mode 100644 examples/memory_service_with_in_memory/agent/agent.py create mode 100644 examples/memory_service_with_in_memory/agent/config.py create mode 100644 examples/memory_service_with_in_memory/agent/prompts.py create mode 100644 examples/memory_service_with_in_memory/agent/tools.py create mode 100644 examples/memory_service_with_in_memory/run_agent.py create mode 100644 examples/memory_service_with_mem0/.env create mode 100644 examples/memory_service_with_mem0/README.md create mode 100644 examples/memory_service_with_mem0/agent/__init__.py create mode 100644 examples/memory_service_with_mem0/agent/agent.py create mode 100644 examples/memory_service_with_mem0/agent/config.py create mode 100644 examples/memory_service_with_mem0/agent/prompts.py create mode 100644 examples/memory_service_with_mem0/agent/tools.py create mode 100644 examples/memory_service_with_mem0/images/local_infer_false.png create mode 100644 examples/memory_service_with_mem0/images/local_infer_true.png create mode 100644 examples/memory_service_with_mem0/images/remote_infer_false.png create mode 100644 examples/memory_service_with_mem0/images/remote_infer_true.png create mode 100644 examples/memory_service_with_mem0/run_agent.py create mode 100644 examples/memory_service_with_mempalace/.env create mode 100644 examples/memory_service_with_mempalace/README.md create mode 100644 examples/memory_service_with_mempalace/agent/__init__.py create mode 100644 examples/memory_service_with_mempalace/agent/agent.py create mode 100644 examples/memory_service_with_mempalace/agent/config.py create mode 100644 examples/memory_service_with_mempalace/agent/prompts.py create mode 100644 examples/memory_service_with_mempalace/agent/tools.py create mode 100644 examples/memory_service_with_mempalace/run_agent.py create mode 100644 examples/memory_service_with_redis/.env create mode 100644 examples/memory_service_with_redis/README.md create mode 100644 examples/memory_service_with_redis/agent/__init__.py create mode 100644 examples/memory_service_with_redis/agent/agent.py create mode 100644 examples/memory_service_with_redis/agent/config.py create mode 100644 examples/memory_service_with_redis/agent/prompts.py create mode 100644 examples/memory_service_with_redis/agent/tools.py create mode 100644 examples/memory_service_with_redis/run_agent.py create mode 100644 examples/memory_service_with_sql/.env create mode 100644 examples/memory_service_with_sql/README.md create mode 100644 examples/memory_service_with_sql/agent/__init__.py create mode 100644 examples/memory_service_with_sql/agent/agent.py create mode 100644 examples/memory_service_with_sql/agent/config.py create mode 100644 examples/memory_service_with_sql/agent/prompts.py create mode 100644 examples/memory_service_with_sql/agent/tools.py create mode 100644 examples/memory_service_with_sql/run_agent.py create mode 100644 examples/mempalace_mcp/.env create mode 100644 examples/mempalace_mcp/README.md create mode 100644 examples/mempalace_mcp/agent/__init__.py create mode 100644 examples/mempalace_mcp/agent/agent.py create mode 100644 examples/mempalace_mcp/agent/config.py create mode 100644 examples/mempalace_mcp/agent/prompts.py create mode 100644 examples/mempalace_mcp/agent/tools.py create mode 100644 examples/mempalace_mcp/run_agent.py create mode 100644 examples/mempalace_tools/.env create mode 100644 examples/mempalace_tools/README.md create mode 100644 examples/mempalace_tools/agent/__init__.py create mode 100644 examples/mempalace_tools/agent/agent.py create mode 100644 examples/mempalace_tools/agent/config.py create mode 100644 examples/mempalace_tools/agent/prompts.py create mode 100644 examples/mempalace_tools/agent/tools.py create mode 100644 examples/mempalace_tools/out.txt create mode 100644 examples/mempalace_tools/run_agent.py create mode 100644 examples/multi_agent_chain/.env create mode 100644 examples/multi_agent_chain/README.md create mode 100644 examples/multi_agent_chain/agent/__init__.py create mode 100644 examples/multi_agent_chain/agent/agent.py create mode 100644 examples/multi_agent_chain/agent/config.py create mode 100644 examples/multi_agent_chain/agent/prompts.py create mode 100644 examples/multi_agent_chain/agent/tools.py create mode 100644 examples/multi_agent_chain/run_agent.py create mode 100644 examples/multi_agent_compose/.env create mode 100644 examples/multi_agent_compose/README.md create mode 100644 examples/multi_agent_compose/agent/__init__.py create mode 100644 examples/multi_agent_compose/agent/agent.py create mode 100644 examples/multi_agent_compose/agent/config.py create mode 100644 examples/multi_agent_compose/agent/prompts.py create mode 100644 examples/multi_agent_compose/agent/tools.py create mode 100644 examples/multi_agent_compose/run_agent.py create mode 100644 examples/multi_agent_cycle/.env create mode 100644 examples/multi_agent_cycle/README.md create mode 100644 examples/multi_agent_cycle/agent/__init__.py create mode 100644 examples/multi_agent_cycle/agent/agent.py create mode 100644 examples/multi_agent_cycle/agent/config.py create mode 100644 examples/multi_agent_cycle/agent/prompts.py create mode 100644 examples/multi_agent_cycle/agent/tools.py create mode 100644 examples/multi_agent_cycle/run_agent.py create mode 100644 examples/multi_agent_parallel/.env create mode 100644 examples/multi_agent_parallel/README.md create mode 100644 examples/multi_agent_parallel/agent/__init__.py create mode 100644 examples/multi_agent_parallel/agent/agent.py create mode 100644 examples/multi_agent_parallel/agent/config.py create mode 100644 examples/multi_agent_parallel/agent/prompts.py create mode 100644 examples/multi_agent_parallel/agent/tools.py create mode 100644 examples/multi_agent_parallel/run_agent.py create mode 100644 examples/multi_agent_start_from_last/.env create mode 100644 examples/multi_agent_start_from_last/README.md create mode 100644 examples/multi_agent_start_from_last/agent/__init__.py create mode 100644 examples/multi_agent_start_from_last/agent/agent.py create mode 100644 examples/multi_agent_start_from_last/agent/config.py create mode 100644 examples/multi_agent_start_from_last/agent/prompts.py create mode 100644 examples/multi_agent_start_from_last/agent/tools.py create mode 100644 examples/multi_agent_start_from_last/run_agent.py create mode 100644 examples/multi_agent_subagent/.env create mode 100644 examples/multi_agent_subagent/README.md create mode 100644 examples/multi_agent_subagent/agent/__init__.py create mode 100644 examples/multi_agent_subagent/agent/agent.py create mode 100644 examples/multi_agent_subagent/agent/config.py create mode 100644 examples/multi_agent_subagent/agent/prompts.py create mode 100644 examples/multi_agent_subagent/agent/tools.py create mode 100644 examples/multi_agent_subagent/run_agent.py create mode 100644 examples/optimization/advanced_strategies/README.md create mode 100644 examples/optimization/advanced_strategies/agent/__init__.py create mode 100644 examples/optimization/advanced_strategies/agent/agent.py create mode 100644 examples/optimization/advanced_strategies/agent/config.py create mode 100644 examples/optimization/advanced_strategies/agent/prompts/system.md create mode 100644 examples/optimization/advanced_strategies/compare.py create mode 100644 examples/optimization/advanced_strategies/data/train.evalset.json create mode 100644 examples/optimization/advanced_strategies/data/val.evalset.json create mode 100644 examples/optimization/advanced_strategies/optimizer_advanced.json create mode 100644 examples/optimization/advanced_strategies/optimizer_baseline.json create mode 100644 examples/optimization/advanced_strategies/run_advanced.py create mode 100644 examples/optimization/advanced_strategies/run_baseline.py create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-24-38/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-24-38/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-24-38/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-24-38/result.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-24-38/summary.txt create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-25-41/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-25-41/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-25-41/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-25-41/result.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-25-41/summary.txt create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/result.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/rounds/round_001.json create mode 100644 examples/optimization/advanced_strategies/runs/advanced_2026-07-30T17-27-07/summary.txt create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/result.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/rounds/round_001.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/rounds/round_002.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/rounds/round_003.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/rounds/round_004.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-24-35/summary.txt create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/result.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/rounds/round_001.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/rounds/round_002.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/rounds/round_003.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/rounds/round_004.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-25-38/summary.txt create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/baseline_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/best_prompts/system_prompt.md create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/config.snapshot.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/result.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/rounds/round_001.json create mode 100644 examples/optimization/advanced_strategies/runs/baseline_2026-07-30T17-26-38/summary.txt create mode 100644 examples/optimization/blackbox_cli/README.md create mode 100644 examples/optimization/blackbox_cli/agent/__init__.py create mode 100644 examples/optimization/blackbox_cli/agent/call_agent.py create mode 100644 examples/optimization/blackbox_cli/optimizer.json create mode 100644 examples/optimization/blackbox_cli/run_optimization.py create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/baseline_prompts/claude_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/baseline_prompts/skill_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/best_prompts/claude_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/best_prompts/skill_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/config.snapshot.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/result.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/rounds/round_001.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/rounds/round_002.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/rounds/round_003.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/rounds/round_004.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-42-38/summary.txt create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-49-15/baseline_prompts/claude_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-49-15/baseline_prompts/skill_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-49-15/config.snapshot.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/baseline_prompts/claude_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/baseline_prompts/skill_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/best_prompts/claude_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/best_prompts/skill_md.md create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/config.snapshot.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/result.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/rounds/round_001.json create mode 100644 examples/optimization/blackbox_cli/runs/2026-07-30T17-52-59/summary.txt create mode 100644 examples/optimization/blackbox_cli/train.evalset.json create mode 100644 examples/optimization/blackbox_cli/val.evalset.json create mode 100644 examples/optimization/blackbox_cli/workspace/CLAUDE.md create mode 100644 examples/optimization/ci_integration/README.md create mode 100644 examples/optimization/ci_integration/agent/__init__.py create mode 100644 examples/optimization/ci_integration/agent/agent.py create mode 100644 examples/optimization/ci_integration/agent/config.py create mode 100644 examples/optimization/ci_integration/agent/prompts/skill.md create mode 100644 examples/optimization/ci_integration/agent/prompts/system.md create mode 100755 examples/optimization/ci_integration/ci/run_nightly_optimize.sh create mode 100755 examples/optimization/ci_integration/ci/run_pr_check.sh create mode 100644 examples/optimization/ci_integration/data/test_config.json create mode 100644 examples/optimization/ci_integration/data/train.evalset.json create mode 100644 examples/optimization/ci_integration/data/val.evalset.json create mode 100644 examples/optimization/ci_integration/optimizer.json create mode 100644 examples/optimization/ci_integration/run_optimization.py create mode 100644 examples/optimization/ci_integration/tests/__init__.py create mode 100644 examples/optimization/ci_integration/tests/test_agent_quality.py create mode 100644 examples/optimization/eval_optimize_loop/README.md create mode 100644 examples/optimization/eval_optimize_loop/agent/__init__.py create mode 100644 examples/optimization/eval_optimize_loop/agent/agent.py create mode 100644 examples/optimization/eval_optimize_loop/agent/config.py create mode 100644 examples/optimization/eval_optimize_loop/agent/prompts/system.md create mode 100644 examples/optimization/eval_optimize_loop/agent/tools.py create mode 100644 examples/optimization/eval_optimize_loop/data/_generate_evalsets.py create mode 100644 examples/optimization/eval_optimize_loop/data/demo_optimize_result.json create mode 100644 examples/optimization/eval_optimize_loop/data/optimizer.json create mode 100644 examples/optimization/eval_optimize_loop/data/test_config.json create mode 100644 examples/optimization/eval_optimize_loop/data/train_baseline.evalset.json create mode 100644 examples/optimization/eval_optimize_loop/data/val_baseline.evalset.json create mode 100644 examples/optimization/eval_optimize_loop/design.md create mode 100644 examples/optimization/eval_optimize_loop/pipeline/__init__.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_models.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_runner.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_acceptance_gate.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_audit_trail.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_baseline.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_failure_attribution.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_optimization.py create mode 100644 examples/optimization/eval_optimize_loop/pipeline/_stage_validation.py create mode 100644 examples/optimization/eval_optimize_loop/run_pipeline.py create mode 100644 examples/optimization/eval_optimize_loop/tests/__init__.py create mode 100644 examples/optimization/eval_optimize_loop/tests/conftest.py create mode 100644 examples/optimization/eval_optimize_loop/tests/test_acceptance_gate.py create mode 100644 examples/optimization/eval_optimize_loop/tests/test_failure_attribution.py create mode 100644 examples/optimization/eval_optimize_loop/tests/test_models.py create mode 100644 examples/optimization/eval_optimize_loop/tests/test_optimizer_config.py create mode 100644 examples/optimization/eval_optimize_loop/tests/test_pipeline_fake.py create mode 100644 examples/optimization/http_service/README.md create mode 100644 examples/optimization/http_service/optimizer.json create mode 100644 examples/optimization/http_service/run_optimization.py create mode 100644 examples/optimization/http_service/service/__init__.py create mode 100644 examples/optimization/http_service/service/prompts/system.md create mode 100644 examples/optimization/http_service/service/server.py create mode 100644 examples/optimization/http_service/train.evalset.json create mode 100644 examples/optimization/http_service/val.evalset.json create mode 100644 examples/optimization/multi_agent_pipeline/README.md create mode 100644 examples/optimization/multi_agent_pipeline/optimizer.json create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/__init__.py create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/config.py create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/orchestrator.py create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/prompts/fact_agent.md create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/prompts/math_agent.md create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/prompts/router.md create mode 100644 examples/optimization/multi_agent_pipeline/pipeline/prompts/summarizer.md create mode 100644 examples/optimization/multi_agent_pipeline/run_optimization.py create mode 100644 examples/optimization/multi_agent_pipeline/train.evalset.json create mode 100644 examples/optimization/multi_agent_pipeline/val.evalset.json create mode 100644 examples/optimization/multi_metric_with_judges/README.md create mode 100644 examples/optimization/multi_metric_with_judges/agent/__init__.py create mode 100644 examples/optimization/multi_metric_with_judges/agent/agent.py create mode 100644 examples/optimization/multi_metric_with_judges/agent/config.py create mode 100644 examples/optimization/multi_metric_with_judges/agent/prompts/system.md create mode 100644 examples/optimization/multi_metric_with_judges/optimizer.json create mode 100644 examples/optimization/multi_metric_with_judges/run_optimization.py create mode 100644 examples/optimization/multi_metric_with_judges/train.evalset.json create mode 100644 examples/optimization/multi_metric_with_judges/val.evalset.json create mode 100644 examples/optimization/quickstart/README.md create mode 100644 examples/optimization/quickstart/agent/__init__.py create mode 100644 examples/optimization/quickstart/agent/agent.py create mode 100644 examples/optimization/quickstart/agent/config.py create mode 100644 examples/optimization/quickstart/agent/prompts/skill.md create mode 100644 examples/optimization/quickstart/agent/prompts/system.md create mode 100644 examples/optimization/quickstart/optimizer.json create mode 100644 examples/optimization/quickstart/run_optimization.py create mode 100644 examples/optimization/quickstart/train.evalset.json create mode 100644 examples/optimization/quickstart/val.evalset.json create mode 100644 examples/optimization/remote_prompt_store/README.md create mode 100644 examples/optimization/remote_prompt_store/agent/__init__.py create mode 100644 examples/optimization/remote_prompt_store/agent/agent.py create mode 100644 examples/optimization/remote_prompt_store/agent/config.py create mode 100644 examples/optimization/remote_prompt_store/optimizer.json create mode 100644 examples/optimization/remote_prompt_store/run_optimization.py create mode 100644 examples/optimization/remote_prompt_store/store/__init__.py create mode 100644 examples/optimization/remote_prompt_store/store/fake_kv_store.py create mode 100644 examples/optimization/remote_prompt_store/store/prompt_client.py create mode 100644 examples/optimization/remote_prompt_store/store/store.json create mode 100644 examples/optimization/remote_prompt_store/train.evalset.json create mode 100644 examples/optimization/remote_prompt_store/val.evalset.json create mode 100644 examples/optimization/slo_runtime_control/README.md create mode 100644 examples/optimization/slo_runtime_control/agent/__init__.py create mode 100644 examples/optimization/slo_runtime_control/agent/agent.py create mode 100644 examples/optimization/slo_runtime_control/agent/config.py create mode 100644 examples/optimization/slo_runtime_control/agent/prompts/system.md create mode 100644 examples/optimization/slo_runtime_control/optimizer.json create mode 100644 examples/optimization/slo_runtime_control/run_optimization.py create mode 100644 examples/optimization/slo_runtime_control/train.evalset.json create mode 100644 examples/optimization/slo_runtime_control/val.evalset.json create mode 100644 examples/quickstart/.env create mode 100644 examples/quickstart/README.md create mode 100644 examples/quickstart/agent/__init__.py create mode 100644 examples/quickstart/agent/agent.py create mode 100644 examples/quickstart/agent/config.py create mode 100644 examples/quickstart/agent/prompts.py create mode 100644 examples/quickstart/agent/tools.py create mode 100644 examples/quickstart/run_agent.py create mode 100644 examples/session_service_with_in_memory/.env create mode 100644 examples/session_service_with_in_memory/README.md create mode 100644 examples/session_service_with_in_memory/agent/__init__.py create mode 100644 examples/session_service_with_in_memory/agent/agent.py create mode 100644 examples/session_service_with_in_memory/agent/config.py create mode 100644 examples/session_service_with_in_memory/agent/prompts.py create mode 100644 examples/session_service_with_in_memory/agent/tools.py create mode 100644 examples/session_service_with_in_memory/run_agent.py create mode 100644 examples/session_service_with_redis/.env create mode 100644 examples/session_service_with_redis/README.md create mode 100644 examples/session_service_with_redis/agent/__init__.py create mode 100644 examples/session_service_with_redis/agent/agent.py create mode 100644 examples/session_service_with_redis/agent/config.py create mode 100644 examples/session_service_with_redis/agent/prompts.py create mode 100644 examples/session_service_with_redis/agent/tools.py create mode 100644 examples/session_service_with_redis/run_agent.py create mode 100644 examples/session_service_with_sql/.env create mode 100644 examples/session_service_with_sql/README.md create mode 100644 examples/session_service_with_sql/agent/__init__.py create mode 100644 examples/session_service_with_sql/agent/agent.py create mode 100644 examples/session_service_with_sql/agent/config.py create mode 100644 examples/session_service_with_sql/agent/prompts.py create mode 100644 examples/session_service_with_sql/agent/tools.py create mode 100644 examples/session_service_with_sql/run_agent.py create mode 100644 examples/session_state/.env create mode 100644 examples/session_state/README.md create mode 100644 examples/session_state/agent/__init__.py create mode 100644 examples/session_state/agent/agent.py create mode 100644 examples/session_state/agent/config.py create mode 100644 examples/session_state/agent/prompts.py create mode 100644 examples/session_state/agent/tools.py create mode 100644 examples/session_state/agent/utils.py create mode 100644 examples/session_state/run_agent.py create mode 100644 examples/session_summarizer/.env create mode 100644 examples/session_summarizer/README.md create mode 100644 examples/session_summarizer/agent/__init__.py create mode 100644 examples/session_summarizer/agent/agent.py create mode 100644 examples/session_summarizer/agent/config.py create mode 100644 examples/session_summarizer/agent/filters.py create mode 100644 examples/session_summarizer/agent/prompts.py create mode 100644 examples/session_summarizer/agent/tools.py create mode 100644 examples/session_summarizer/run_agent.py create mode 100644 examples/skills/.env create mode 100644 examples/skills/README.md create mode 100644 examples/skills/agent/__init__.py create mode 100644 examples/skills/agent/agent.py create mode 100644 examples/skills/agent/config.py create mode 100644 examples/skills/agent/prompts.py create mode 100644 examples/skills/agent/tools.py create mode 100644 examples/skills/run_agent.py create mode 100644 examples/skills/skills/data_analysis/SKILL.md create mode 100644 examples/skills/skills/data_analysis/references/data_analysis_patterns.md create mode 100644 examples/skills/skills/data_analysis/references/numpy_basics.txt create mode 100644 examples/skills/skills/data_analysis/references/pandas_guide.md create mode 100755 examples/skills/skills/data_analysis/scripts/analyze_csv.py create mode 100755 examples/skills/skills/data_analysis/scripts/describe_data.py create mode 100755 examples/skills/skills/data_analysis/scripts/summarize_data.py create mode 100644 examples/skills/skills/file_tools/SKILL.md create mode 100644 examples/skills/skills/file_tools/USAGE.md create mode 100644 examples/skills/skills/file_tools/scripts/write_sample.sh create mode 100644 examples/skills/skills/python-math/SKILL.md create mode 100644 examples/skills/skills/python-math/scripts/fib.py create mode 100644 examples/skills/skills/user_file_ops/SKILL.md create mode 100755 examples/skills/skills/user_file_ops/scripts/summarize_file.sh create mode 100644 examples/skills_hub/.env create mode 100644 examples/skills_hub/.gitignore create mode 100644 examples/skills_hub/README.md create mode 100644 examples/skills_hub/agent/__init__.py create mode 100644 examples/skills_hub/agent/agent.py create mode 100644 examples/skills_hub/agent/config.py create mode 100644 examples/skills_hub/agent/hub.py create mode 100644 examples/skills_hub/agent/prompts.py create mode 100644 examples/skills_hub/run_agent.py create mode 100644 examples/skills_with_container/.env create mode 100644 examples/skills_with_container/README.md create mode 100644 examples/skills_with_container/agent/__init__.py create mode 100644 examples/skills_with_container/agent/agent.py create mode 100644 examples/skills_with_container/agent/config.py create mode 100644 examples/skills_with_container/agent/prompts.py create mode 100644 examples/skills_with_container/agent/tools.py create mode 100644 examples/skills_with_container/run_agent.py create mode 100644 examples/skills_with_container/skills/python-math/SKILL.md create mode 100644 examples/skills_with_container/skills/python-math/scripts/fib.py create mode 100644 examples/skills_with_cube/.env create mode 100644 examples/skills_with_cube/README.md create mode 100644 examples/skills_with_cube/agent/__init__.py create mode 100644 examples/skills_with_cube/agent/agent.py create mode 100644 examples/skills_with_cube/agent/config.py create mode 100644 examples/skills_with_cube/agent/prompts.py create mode 100644 examples/skills_with_cube/agent/tools.py create mode 100644 examples/skills_with_cube/run_agent.py create mode 100644 examples/skills_with_cube/skills/python-math/SKILL.md create mode 100644 examples/skills_with_cube/skills/python-math/scripts/fib.py create mode 100644 examples/skills_with_dynamic_tools/.env create mode 100644 examples/skills_with_dynamic_tools/README.md create mode 100644 examples/skills_with_dynamic_tools/agent/__init__.py create mode 100644 examples/skills_with_dynamic_tools/agent/agent.py create mode 100644 examples/skills_with_dynamic_tools/agent/config.py create mode 100644 examples/skills_with_dynamic_tools/agent/prompts.py create mode 100644 examples/skills_with_dynamic_tools/agent/tools/__init__.py create mode 100644 examples/skills_with_dynamic_tools/agent/tools/_dynamic.py create mode 100644 examples/skills_with_dynamic_tools/agent/tools/_skill_tools.py create mode 100644 examples/skills_with_dynamic_tools/agent/tools/_tools.py create mode 100644 examples/skills_with_dynamic_tools/run_agent.py create mode 100644 examples/skills_with_dynamic_tools/skills/weather-tools/SKILL.md create mode 100644 examples/spawn_subagent/.env create mode 100644 examples/spawn_subagent/.trpc_agents/security-auditor.md create mode 100644 examples/spawn_subagent/README.md create mode 100644 examples/spawn_subagent/agent/__init__.py create mode 100644 examples/spawn_subagent/agent/agent.py create mode 100644 examples/spawn_subagent/agent/config.py create mode 100644 examples/spawn_subagent/agent/prompts.py create mode 100644 examples/spawn_subagent/run_agent.py create mode 100644 examples/spawn_subagent/sample_repo/README.md create mode 100644 examples/spawn_subagent/sample_repo/app.py create mode 100644 examples/spawn_subagent/sample_repo/auth.py create mode 100644 examples/spawn_subagent/sample_repo/cart.py create mode 100644 examples/spawn_subagent/sample_repo/db.py create mode 100644 examples/streaming_tools/.env create mode 100644 examples/streaming_tools/README.md create mode 100644 examples/streaming_tools/agent/__init__.py create mode 100644 examples/streaming_tools/agent/agent.py create mode 100644 examples/streaming_tools/agent/config.py create mode 100644 examples/streaming_tools/agent/prompts.py create mode 100644 examples/streaming_tools/agent/tools.py create mode 100644 examples/streaming_tools/run_agent.py create mode 100644 examples/task_tools/.env create mode 100644 examples/task_tools/README.md create mode 100644 examples/task_tools/agent/__init__.py create mode 100644 examples/task_tools/agent/agent.py create mode 100644 examples/task_tools/agent/config.py create mode 100644 examples/task_tools/agent/prompts.py create mode 100644 examples/task_tools/run_agent.py create mode 100644 examples/team/.env create mode 100644 examples/team/README.md create mode 100644 examples/team/agent/__init__.py create mode 100644 examples/team/agent/agent.py create mode 100644 examples/team/agent/config.py create mode 100644 examples/team/agent/prompts.py create mode 100644 examples/team/agent/tools.py create mode 100644 examples/team/run_agent.py create mode 100644 examples/team_as_sub_agent/.env create mode 100644 examples/team_as_sub_agent/README.md create mode 100644 examples/team_as_sub_agent/agent/__init__.py create mode 100644 examples/team_as_sub_agent/agent/agent.py create mode 100644 examples/team_as_sub_agent/agent/config.py create mode 100644 examples/team_as_sub_agent/agent/prompts.py create mode 100644 examples/team_as_sub_agent/agent/tools.py create mode 100644 examples/team_as_sub_agent/run_agent.py create mode 100644 examples/team_human_in_the_loop/.env create mode 100644 examples/team_human_in_the_loop/README.md create mode 100644 examples/team_human_in_the_loop/agent/__init__.py create mode 100644 examples/team_human_in_the_loop/agent/agent.py create mode 100644 examples/team_human_in_the_loop/agent/config.py create mode 100644 examples/team_human_in_the_loop/agent/prompts.py create mode 100644 examples/team_human_in_the_loop/agent/tools.py create mode 100644 examples/team_human_in_the_loop/run_agent.py create mode 100644 examples/team_member_agent_claude/.env create mode 100644 examples/team_member_agent_claude/README.md create mode 100644 examples/team_member_agent_claude/agent/__init__.py create mode 100644 examples/team_member_agent_claude/agent/agent.py create mode 100644 examples/team_member_agent_claude/agent/config.py create mode 100644 examples/team_member_agent_claude/agent/prompts.py create mode 100644 examples/team_member_agent_claude/agent/tools.py create mode 100644 examples/team_member_agent_claude/run_agent.py create mode 100644 examples/team_member_agent_langgraph/.env create mode 100644 examples/team_member_agent_langgraph/README.md create mode 100644 examples/team_member_agent_langgraph/agent/__init__.py create mode 100644 examples/team_member_agent_langgraph/agent/agent.py create mode 100644 examples/team_member_agent_langgraph/agent/config.py create mode 100644 examples/team_member_agent_langgraph/agent/prompts.py create mode 100644 examples/team_member_agent_langgraph/agent/tools.py create mode 100644 examples/team_member_agent_langgraph/run_agent.py create mode 100644 examples/team_member_agent_team/.env create mode 100644 examples/team_member_agent_team/README.md create mode 100644 examples/team_member_agent_team/agent/__init__.py create mode 100644 examples/team_member_agent_team/agent/agent.py create mode 100644 examples/team_member_agent_team/agent/config.py create mode 100644 examples/team_member_agent_team/agent/prompts.py create mode 100644 examples/team_member_agent_team/agent/tools.py create mode 100644 examples/team_member_agent_team/run_agent.py create mode 100644 examples/team_member_message_filter/.env create mode 100644 examples/team_member_message_filter/README.md create mode 100644 examples/team_member_message_filter/agent/__init__.py create mode 100644 examples/team_member_message_filter/agent/agent.py create mode 100644 examples/team_member_message_filter/agent/config.py create mode 100644 examples/team_member_message_filter/agent/prompts.py create mode 100644 examples/team_member_message_filter/agent/tools.py create mode 100644 examples/team_member_message_filter/run_agent.py create mode 100644 examples/team_parallel_execution/.env create mode 100644 examples/team_parallel_execution/README.md create mode 100644 examples/team_parallel_execution/agent/__init__.py create mode 100644 examples/team_parallel_execution/agent/agent.py create mode 100644 examples/team_parallel_execution/agent/config.py create mode 100644 examples/team_parallel_execution/agent/prompts.py create mode 100644 examples/team_parallel_execution/agent/tools.py create mode 100644 examples/team_parallel_execution/run_agent.py create mode 100644 examples/team_with_cancel/.env create mode 100644 examples/team_with_cancel/README.md create mode 100644 examples/team_with_cancel/agent/__init__.py create mode 100644 examples/team_with_cancel/agent/agent.py create mode 100644 examples/team_with_cancel/agent/config.py create mode 100644 examples/team_with_cancel/agent/prompts.py create mode 100644 examples/team_with_cancel/agent/tools.py create mode 100644 examples/team_with_cancel/run_agent.py create mode 100644 examples/team_with_skill/.env create mode 100644 examples/team_with_skill/README.md create mode 100644 examples/team_with_skill/agent/__init__.py create mode 100644 examples/team_with_skill/agent/agent.py create mode 100644 examples/team_with_skill/agent/config.py create mode 100644 examples/team_with_skill/agent/prompts.py create mode 100644 examples/team_with_skill/agent/tools.py create mode 100644 examples/team_with_skill/run_agent.py create mode 100644 examples/team_with_skill/skills/leader_research/SKILL.md create mode 100755 examples/team_with_skill/skills/leader_research/scripts/gather_points.sh create mode 100644 examples/todo_tool/.env create mode 100644 examples/todo_tool/README.md create mode 100644 examples/todo_tool/agent/__init__.py create mode 100644 examples/todo_tool/agent/agent.py create mode 100644 examples/todo_tool/agent/config.py create mode 100644 examples/todo_tool/agent/prompts.py create mode 100644 examples/todo_tool/run_agent.py create mode 100644 examples/todo_tool_with_human_in_the_loop/.env create mode 100644 examples/todo_tool_with_human_in_the_loop/README.md create mode 100644 examples/todo_tool_with_human_in_the_loop/agent/__init__.py create mode 100644 examples/todo_tool_with_human_in_the_loop/agent/agent.py create mode 100644 examples/todo_tool_with_human_in_the_loop/agent/config.py create mode 100644 examples/todo_tool_with_human_in_the_loop/agent/prompts.py create mode 100644 examples/todo_tool_with_human_in_the_loop/agent/tools.py create mode 100644 examples/todo_tool_with_human_in_the_loop/run_agent.py create mode 100644 examples/tools/.env create mode 100644 examples/tools/README.md create mode 100644 examples/tools/__init__.py create mode 100644 examples/tools/agent/.env create mode 100644 examples/tools/agent/__init__.py create mode 100644 examples/tools/agent/agent.py create mode 100644 examples/tools/agent/config.py create mode 100644 examples/tools/agent/function_tool.py create mode 100644 examples/tools/agent/langchain_tool.py create mode 100644 examples/tools/agent/prompts.py create mode 100644 examples/tools/agent/toolset.py create mode 100644 examples/tools/run_agent.py create mode 100644 examples/toolsets/.env create mode 100644 examples/toolsets/README.md create mode 100644 examples/toolsets/agent/__init__.py create mode 100644 examples/toolsets/agent/agent.py create mode 100644 examples/toolsets/agent/config.py create mode 100644 examples/toolsets/agent/prompts.py create mode 100644 examples/toolsets/agent/tools.py create mode 100644 examples/toolsets/run_agent.py create mode 100644 examples/transfer_agent/.env create mode 100644 examples/transfer_agent/README.md create mode 100644 examples/transfer_agent/agent/__init__.py create mode 100644 examples/transfer_agent/agent/agent.py create mode 100644 examples/transfer_agent/agent/config.py create mode 100644 examples/transfer_agent/agent/prompts.py create mode 100644 examples/transfer_agent/agent/tools.py create mode 100644 examples/transfer_agent/run_agent.py create mode 100644 examples/webfetch_tool/.env create mode 100644 examples/webfetch_tool/README.md create mode 100644 examples/webfetch_tool/agent/__init__.py create mode 100644 examples/webfetch_tool/agent/agent.py create mode 100644 examples/webfetch_tool/agent/config.py create mode 100644 examples/webfetch_tool/agent/prompts.py create mode 100644 examples/webfetch_tool/run_agent.py create mode 100644 examples/websearch_tool/.env create mode 100644 examples/websearch_tool/README.md create mode 100644 examples/websearch_tool/agent/__init__.py create mode 100644 examples/websearch_tool/agent/agent.py create mode 100644 examples/websearch_tool/agent/config.py create mode 100644 examples/websearch_tool/agent/prompts.py create mode 100644 examples/websearch_tool/run_agent.py create mode 100644 format.py create mode 100755 format.sh create mode 100644 lint_flake8.sh create mode 100644 pyproject.toml create mode 100644 requirements-test.txt create mode 100644 requirements.txt create mode 100644 run_example.sh create mode 100644 stop.sh create mode 100644 tests/__init__.py create mode 100644 tests/abc/__init__.py create mode 100644 tests/abc/conftest.py create mode 100644 tests/abc/test_agent.py create mode 100644 tests/abc/test_artifact_service.py create mode 100644 tests/abc/test_filter.py create mode 100644 tests/abc/test_memory_service.py create mode 100644 tests/abc/test_session.py create mode 100644 tests/abc/test_toolset.py create mode 100644 tests/agents/__init__.py create mode 100644 tests/agents/core/__init__.py create mode 100644 tests/agents/core/test_agent_transfer_processor.py create mode 100644 tests/agents/core/test_code_execution_processor.py create mode 100644 tests/agents/core/test_history_processor.py create mode 100644 tests/agents/core/test_llm_processor.py create mode 100644 tests/agents/core/test_output_schema_processor.py create mode 100644 tests/agents/core/test_request_processor.py create mode 100644 tests/agents/core/test_request_processor_ext.py create mode 100644 tests/agents/core/test_skill_processor.py create mode 100644 tests/agents/core/test_skill_tool_result_processor.py create mode 100644 tests/agents/core/test_tools_processor.py create mode 100644 tests/agents/core/test_workspace_exec_processor.py create mode 100644 tests/agents/sub_agent/__init__.py create mode 100644 tests/agents/sub_agent/test_archetype.py create mode 100644 tests/agents/sub_agent/test_defaults.py create mode 100644 tests/agents/sub_agent/test_description.py create mode 100644 tests/agents/sub_agent/test_dynamic_sub_agent_tool.py create mode 100644 tests/agents/sub_agent/test_imports.py create mode 100644 tests/agents/sub_agent/test_loader.py create mode 100644 tests/agents/sub_agent/test_registry.py create mode 100644 tests/agents/sub_agent/test_runner.py create mode 100644 tests/agents/sub_agent/test_spawn_sub_agent_tool.py create mode 100644 tests/agents/test_base_agent.py create mode 100644 tests/agents/test_callback.py create mode 100644 tests/agents/test_chain_agent.py create mode 100644 tests/agents/test_constants.py create mode 100644 tests/agents/test_cycle_agent.py create mode 100644 tests/agents/test_langgraph_agent.py create mode 100644 tests/agents/test_llm_agent.py create mode 100644 tests/agents/test_llm_agent_ext.py create mode 100644 tests/agents/test_parallel_agent.py create mode 100644 tests/agents/test_parallel_agent_ext.py create mode 100644 tests/agents/test_transfer_agent.py create mode 100644 tests/agents/utils/__init__.py create mode 100644 tests/agents/utils/test_langgraph_event_writer.py create mode 100644 tests/artifacts/__init__.py create mode 100644 tests/artifacts/conftest.py create mode 100644 tests/artifacts/test_in_memory_artifact_service.py create mode 100644 tests/artifacts/test_utils.py create mode 100644 tests/cancel/__init__.py create mode 100644 tests/cancel/test_cancel.py create mode 100644 tests/cancel/test_session_utils.py create mode 100644 tests/code_executors/__init__.py create mode 100644 tests/code_executors/container/__init__.py create mode 100644 tests/code_executors/container/test_container_cli.py create mode 100644 tests/code_executors/container/test_container_code_executor.py create mode 100644 tests/code_executors/container/test_container_ws_runtime.py create mode 100644 tests/code_executors/cube/__init__.py create mode 100644 tests/code_executors/cube/conftest.py create mode 100644 tests/code_executors/cube/test_bug_hunt.py create mode 100644 tests/code_executors/cube/test_code_executor.py create mode 100644 tests/code_executors/cube/test_package_imports.py create mode 100644 tests/code_executors/cube/test_paths.py create mode 100644 tests/code_executors/cube/test_runtime.py create mode 100644 tests/code_executors/cube/test_sandbox.py create mode 100644 tests/code_executors/cube/test_transfer.py create mode 100644 tests/code_executors/cube/test_types.py create mode 100644 tests/code_executors/local/__init__.py create mode 100644 tests/code_executors/local/test_local_ws_runtime.py create mode 100644 tests/code_executors/local/test_unsafe_local_code_executor.py create mode 100644 tests/code_executors/test_artifacts.py create mode 100644 tests/code_executors/test_base_code_executor.py create mode 100644 tests/code_executors/test_base_workspace_fs_collect.py create mode 100644 tests/code_executors/test_base_workspace_runtime.py create mode 100644 tests/code_executors/test_code_executor_context.py create mode 100644 tests/code_executors/test_constants.py create mode 100644 tests/code_executors/test_container_container_code_executor.py create mode 100644 tests/code_executors/test_local_unsafe_local_code_executor.py create mode 100644 tests/code_executors/test_types.py create mode 100644 tests/code_executors/test_utils_code_execution.py create mode 100644 tests/code_executors/test_utils_files.py create mode 100644 tests/code_executors/test_utils_workspace.py create mode 100644 tests/code_executors/utils/__init__.py create mode 100644 tests/code_executors/utils/test_meta.py create mode 100644 tests/common/__init__.py create mode 100644 tests/common/test_compatible.py create mode 100644 tests/common/test_init.py create mode 100644 tests/configs/__init__.py create mode 100644 tests/configs/test_model_retry_config.py create mode 100644 tests/configs/test_run_config.py create mode 100644 tests/context/__init__.py create mode 100644 tests/context/test_agent_context.py create mode 100644 tests/context/test_common.py create mode 100644 tests/context/test_constants.py create mode 100644 tests/context/test_invocation_context.py create mode 100644 tests/evaluation/__init__.py create mode 100644 tests/evaluation/test_agent_evaluator.py create mode 100644 tests/evaluation/test_agent_evaluator_call_agent.py create mode 100644 tests/evaluation/test_agent_optimizer.py create mode 100644 tests/evaluation/test_base_optimizer.py create mode 100644 tests/evaluation/test_criterion_registry.py create mode 100644 tests/evaluation/test_eval_callbacks.py create mode 100644 tests/evaluation/test_eval_callbacks_ext.py create mode 100644 tests/evaluation/test_eval_case.py create mode 100644 tests/evaluation/test_eval_config.py create mode 100644 tests/evaluation/test_eval_criterion.py create mode 100644 tests/evaluation/test_eval_criterion_ext.py create mode 100644 tests/evaluation/test_eval_metrics.py create mode 100644 tests/evaluation/test_eval_pass.py create mode 100644 tests/evaluation/test_eval_result.py create mode 100644 tests/evaluation/test_eval_service_base.py create mode 100644 tests/evaluation/test_eval_session_service.py create mode 100644 tests/evaluation/test_eval_set.py create mode 100644 tests/evaluation/test_eval_set_results_manager_utils.py create mode 100644 tests/evaluation/test_eval_set_results_manager_utils_ext.py create mode 100644 tests/evaluation/test_eval_sets_manager_utils.py create mode 100644 tests/evaluation/test_evaluator_registry.py create mode 100644 tests/evaluation/test_final_response_evaluator.py create mode 100644 tests/evaluation/test_in_memory_eval_sets_manager.py create mode 100644 tests/evaluation/test_llm_criterion.py create mode 100644 tests/evaluation/test_llm_evaluator_registry.py create mode 100644 tests/evaluation/test_llm_judge.py create mode 100644 tests/evaluation/test_llm_judge_models_aggregator.py create mode 100644 tests/evaluation/test_llm_judge_multi_model.py create mode 100644 tests/evaluation/test_llm_judge_think.py create mode 100644 tests/evaluation/test_local_eval_sets_manager.py create mode 100644 tests/evaluation/test_optimize_config.py create mode 100644 tests/evaluation/test_optimize_evaluator_call.py create mode 100644 tests/evaluation/test_optimize_gepa_adapter.py create mode 100644 tests/evaluation/test_optimize_gepa_callback.py create mode 100644 tests/evaluation/test_optimize_gepa_e2e.py create mode 100644 tests/evaluation/test_optimize_gepa_reflective.py create mode 100644 tests/evaluation/test_optimize_metric_info.py create mode 100644 tests/evaluation/test_optimize_model_callable.py create mode 100644 tests/evaluation/test_optimize_model_options.py create mode 100644 tests/evaluation/test_optimize_quickstart_example.py create mode 100644 tests/evaluation/test_optimize_registry.py create mode 100644 tests/evaluation/test_optimize_reporter.py create mode 100644 tests/evaluation/test_optimize_result.py create mode 100644 tests/evaluation/test_remote_eval_service.py create mode 100644 tests/evaluation/test_rouge_evaluator.py create mode 100644 tests/evaluation/test_static_user_simulator.py create mode 100644 tests/evaluation/test_target_prompt.py create mode 100644 tests/evaluation/test_trajectory_evaluator.py create mode 100644 tests/evaluation/test_trajectory_evaluator_ext.py create mode 100644 tests/evaluation/test_user_simulator.py create mode 100644 tests/evaluation/test_user_simulator_provider.py create mode 100644 tests/evaluation/test_utils.py create mode 100644 tests/evaluation/test_utils_ext.py create mode 100644 tests/events/__init__.py create mode 100644 tests/events/test_agent_cancelled_event.py create mode 100644 tests/events/test_cache_analyzer.py create mode 100644 tests/events/test_event.py create mode 100644 tests/events/test_event_translator.py create mode 100644 tests/events/test_long_running_event.py create mode 100644 tests/events/test_utils.py create mode 100644 tests/exceptions/__init__.py create mode 100644 tests/exceptions/test_exceptions.py create mode 100644 tests/file_tools/__init__.py create mode 100644 tests/file_tools/test_bash_tool.py create mode 100644 tests/file_tools/test_edit_tool.py create mode 100644 tests/file_tools/test_file_utils.py create mode 100644 tests/file_tools/test_glob_tool.py create mode 100644 tests/file_tools/test_grep_tool.py create mode 100644 tests/file_tools/test_read_tool.py create mode 100644 tests/file_tools/test_write_tool.py create mode 100644 tests/filter/__init__.py create mode 100644 tests/filter/test_base_filter.py create mode 100644 tests/filter/test_filter_runner.py create mode 100644 tests/filter/test_registry.py create mode 100644 tests/filter/test_run_filter.py create mode 100644 tests/knowledge/__init__.py create mode 100644 tests/knowledge/test_filter_expr.py create mode 100644 tests/knowledge/test_knowledge.py create mode 100644 tests/langfuse/__init__.py create mode 100644 tests/langfuse/prompt/__init__.py create mode 100644 tests/langfuse/prompt/test_manager.py create mode 100644 tests/langfuse/tracing/__init__.py create mode 100644 tests/langfuse/tracing/test_langfuse_reporting_fixtures.py create mode 100644 tests/langfuse/tracing/test_opentelemetry.py create mode 100644 tests/log/__init__.py create mode 100644 tests/log/test_base_logger.py create mode 100644 tests/log/test_default_logger.py create mode 100644 tests/log/test_logger.py create mode 100644 tests/memory/__init__.py create mode 100644 tests/memory/test_in_memory_memory_service.py create mode 100644 tests/memory/test_mem0_memory_service.py create mode 100644 tests/memory/test_mempalace_memory_service.py create mode 100644 tests/memory/test_redis_memory_service.py create mode 100644 tests/memory/test_sql_memory_service.py create mode 100644 tests/memory/test_utils.py create mode 100644 tests/models/__init__.py create mode 100644 tests/models/test_anthropic_model.py create mode 100644 tests/models/test_anthropic_model_ext.py create mode 100644 tests/models/test_litellm_model.py create mode 100644 tests/models/test_llm_request.py create mode 100644 tests/models/test_llm_response.py create mode 100644 tests/models/test_openai_model.py create mode 100644 tests/models/test_openai_model_ext.py create mode 100644 tests/models/test_prompt_cache_config.py create mode 100644 tests/models/test_registry.py create mode 100644 tests/models/test_retry.py create mode 100644 tests/models/test_tool_prompt.py create mode 100644 tests/planners/__init__.py create mode 100644 tests/planners/test__built_in_planner.py create mode 100644 tests/planners/test__plan_re_act_planner.py create mode 100644 tests/planners/test__planning_processor.py create mode 100644 tests/server/__init__.py create mode 100644 tests/server/a2a/__init__.py create mode 100644 tests/server/a2a/converters/__init__.py create mode 100644 tests/server/a2a/converters/test_event_converter.py create mode 100644 tests/server/a2a/converters/test_part_converter.py create mode 100644 tests/server/a2a/converters/test_request_converter.py create mode 100644 tests/server/a2a/executor/__init__.py create mode 100644 tests/server/a2a/executor/test_a2a_agent_executor.py create mode 100644 tests/server/a2a/executor/test_task_result_aggregator.py create mode 100644 tests/server/a2a/logs/__init__.py create mode 100644 tests/server/a2a/logs/test_log_utils.py create mode 100644 tests/server/a2a/test_agent_card_builder.py create mode 100644 tests/server/a2a/test_agent_service.py create mode 100644 tests/server/a2a/test_remote_a2a_agent.py create mode 100644 tests/server/a2a/test_utils.py create mode 100644 tests/server/ag_ui/__init__.py create mode 100644 tests/server/ag_ui/_core/__init__.py create mode 100644 tests/server/ag_ui/_core/test_agui_agent.py create mode 100644 tests/server/ag_ui/_core/test_client_proxy_tool.py create mode 100644 tests/server/ag_ui/_core/test_client_proxy_toolset.py create mode 100644 tests/server/ag_ui/_core/test_converters.py create mode 100644 tests/server/ag_ui/_core/test_endpoint.py create mode 100644 tests/server/ag_ui/_core/test_event_translator.py create mode 100644 tests/server/ag_ui/_core/test_execution_state.py create mode 100644 tests/server/ag_ui/_core/test_feed_back_content.py create mode 100644 tests/server/ag_ui/_core/test_http_req.py create mode 100644 tests/server/ag_ui/_core/test_session_manager.py create mode 100644 tests/server/ag_ui/_plugin/__init__.py create mode 100644 tests/server/ag_ui/_plugin/test_langgraph_event_translator.py create mode 100644 tests/server/ag_ui/_plugin/test_manager.py create mode 100644 tests/server/ag_ui/_plugin/test_registry.py create mode 100644 tests/server/ag_ui/_plugin/test_service.py create mode 100644 tests/server/ag_ui/_plugin/test_utils.py create mode 100644 tests/server/agents/__init__.py create mode 100644 tests/server/agents/claude/__init__.py create mode 100644 tests/server/agents/claude/test_claude_agent.py create mode 100644 tests/server/agents/claude/test_proxy.py create mode 100644 tests/server/agents/claude/test_proxy_logger.py create mode 100644 tests/server/agents/claude/test_runtime.py create mode 100644 tests/server/agents/claude/test_session_config.py create mode 100644 tests/server/agents/claude/test_session_manager.py create mode 100644 tests/server/agents/claude/test_setup.py create mode 100644 tests/server/knowledge/__init__.py create mode 100644 tests/server/knowledge/test_langchain_knowledge.py create mode 100644 tests/server/knowledge/tools/__init__.py create mode 100644 tests/server/knowledge/tools/test_langchain_knowledge_searchtool.py create mode 100644 tests/server/openclaw/__init__.py create mode 100644 tests/server/openclaw/agent/__init__.py create mode 100644 tests/server/openclaw/agent/test_agent.py create mode 100644 tests/server/openclaw/agent/test_prompts.py create mode 100644 tests/server/openclaw/channels/__init__.py create mode 100644 tests/server/openclaw/channels/test_command_handler.py create mode 100644 tests/server/openclaw/channels/test_repair.py create mode 100644 tests/server/openclaw/channels/test_wecom.py create mode 100644 tests/server/openclaw/config/__init__.py create mode 100644 tests/server/openclaw/config/test_config.py create mode 100644 tests/server/openclaw/metrics/__init__.py create mode 100644 tests/server/openclaw/metrics/test_langfuse.py create mode 100644 tests/server/openclaw/metrics/test_metrics.py create mode 100644 tests/server/openclaw/service/__init__.py create mode 100644 tests/server/openclaw/service/test_heart_service.py create mode 100644 tests/server/openclaw/session_memory/__init__.py create mode 100644 tests/server/openclaw/session_memory/test_claw_memory_service.py create mode 100644 tests/server/openclaw/session_memory/test_claw_session_service.py create mode 100644 tests/server/openclaw/session_memory/test_claw_summarizer.py create mode 100644 tests/server/openclaw/skill/__init__.py create mode 100644 tests/server/openclaw/skill/test_deps.py create mode 100644 tests/server/openclaw/skill/test_skill_loader.py create mode 100644 tests/server/openclaw/skill/test_skill_parser.py create mode 100644 tests/server/openclaw/skill/test_skill_tool.py create mode 100644 tests/server/openclaw/skill/test_utils.py create mode 100644 tests/server/openclaw/storage/__init__.py create mode 100644 tests/server/openclaw/storage/test_aiofile_storage.py create mode 100644 tests/server/openclaw/storage/test_manager.py create mode 100644 tests/server/openclaw/storage/test_utils.py create mode 100644 tests/server/openclaw/test_claw.py create mode 100644 tests/server/openclaw/test_cli.py create mode 100644 tests/server/openclaw/test_logger.py create mode 100644 tests/server/openclaw/test_ui.py create mode 100644 tests/server/openclaw/test_utils.py create mode 100644 tests/server/openclaw/tools/__init__.py create mode 100644 tests/server/openclaw/tools/test_cron.py create mode 100644 tests/server/openclaw/tools/test_filesystem.py create mode 100644 tests/server/openclaw/tools/test_mcp_tool.py create mode 100644 tests/server/openclaw/tools/test_message.py create mode 100644 tests/server/openclaw/tools/test_shell.py create mode 100644 tests/server/openclaw/tools/test_spawn_task.py create mode 100644 tests/server/openclaw/tools/test_web.py create mode 100644 tests/sessions/__init__.py create mode 100644 tests/sessions/test_base_session_service.py create mode 100644 tests/sessions/test_history_record.py create mode 100644 tests/sessions/test_in_memory_session_service.py create mode 100644 tests/sessions/test_redis_session_service.py create mode 100644 tests/sessions/test_session.py create mode 100644 tests/sessions/test_session_summarizer.py create mode 100644 tests/sessions/test_sql_session_service.py create mode 100644 tests/sessions/test_summarizer_checker.py create mode 100644 tests/sessions/test_summarizer_manager.py create mode 100644 tests/sessions/test_types.py create mode 100644 tests/sessions/test_utils.py create mode 100644 tests/skills/__init__.py create mode 100644 tests/skills/hub/__init__.py create mode 100644 tests/skills/hub/test_claude_marketplace.py create mode 100644 tests/skills/hub/test_clawhub.py create mode 100644 tests/skills/hub/test_github.py create mode 100644 tests/skills/hub/test_hermes_index.py create mode 100644 tests/skills/hub/test_install.py create mode 100644 tests/skills/hub/test_lobehub.py create mode 100644 tests/skills/hub/test_skills_sh.py create mode 100644 tests/skills/hub/test_source.py create mode 100644 tests/skills/hub/test_types.py create mode 100644 tests/skills/hub/test_well_known.py create mode 100644 tests/skills/stager/__init__.py create mode 100644 tests/skills/stager/test_base_stager.py create mode 100644 tests/skills/stager/test_types.py create mode 100644 tests/skills/stager/test_utils.py create mode 100644 tests/skills/test_common.py create mode 100644 tests/skills/test_constants.py create mode 100644 tests/skills/test_dynamic_toolset.py create mode 100644 tests/skills/test_hot_reload.py create mode 100644 tests/skills/test_registry.py create mode 100644 tests/skills/test_repository.py create mode 100644 tests/skills/test_skill_config.py create mode 100644 tests/skills/test_skill_profile.py create mode 100644 tests/skills/test_state_keys.py create mode 100644 tests/skills/test_state_migration.py create mode 100644 tests/skills/test_state_order.py create mode 100644 tests/skills/test_toolset.py create mode 100644 tests/skills/test_types.py create mode 100644 tests/skills/test_url_root.py create mode 100644 tests/skills/test_utils.py create mode 100644 tests/skills/tools/__init__.py create mode 100644 tests/skills/tools/test_common.py create mode 100644 tests/skills/tools/test_file_stager.py create mode 100644 tests/skills/tools/test_save_artifact.py create mode 100644 tests/skills/tools/test_skill_exec.py create mode 100644 tests/skills/tools/test_skill_list.py create mode 100644 tests/skills/tools/test_skill_list_docs.py create mode 100644 tests/skills/tools/test_skill_list_tool.py create mode 100644 tests/skills/tools/test_skill_load.py create mode 100644 tests/skills/tools/test_skill_run.py create mode 100644 tests/skills/tools/test_skill_select_docs.py create mode 100644 tests/skills/tools/test_skill_select_tools.py create mode 100644 tests/skills/tools/test_workspace_exec.py create mode 100644 tests/storage/__init__.py create mode 100644 tests/storage/test_constants.py create mode 100644 tests/storage/test_db.py create mode 100644 tests/storage/test_redis.py create mode 100644 tests/storage/test_sql.py create mode 100644 tests/storage/test_sql_common.py create mode 100644 tests/teams/__init__.py create mode 100644 tests/teams/test_delegation_signal.py create mode 100644 tests/teams/test_delegation_tools.py create mode 100644 tests/teams/test_member_message_filter.py create mode 100644 tests/teams/test_message_builder.py create mode 100644 tests/teams/test_system_message.py create mode 100644 tests/teams/test_team_agent.py create mode 100644 tests/teams/test_team_run_context.py create mode 100644 tests/telemetry/__init__.py create mode 100644 tests/telemetry/test_custom_metrics.py create mode 100644 tests/telemetry/test_custom_trace.py create mode 100644 tests/telemetry/test_metrics.py create mode 100644 tests/telemetry/test_trace.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_version.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/goal_tools/test_goal_tools.py create mode 100644 tests/tools/mcp_tool/__init__.py create mode 100644 tests/tools/mcp_tool/test_mcp_session_manager.py create mode 100644 tests/tools/mcp_tool/test_mcp_tool.py create mode 100644 tests/tools/mcp_tool/test_mcp_toolset.py create mode 100644 tests/tools/mcp_tool/test_types.py create mode 100644 tests/tools/mcp_tool/test_utils.py create mode 100644 tests/tools/task_tools/test_task_tools.py create mode 100644 tests/tools/test_agent_tool.py create mode 100644 tests/tools/test_base_tool.py create mode 100644 tests/tools/test_constants.py create mode 100644 tests/tools/test_context_var.py create mode 100644 tests/tools/test_default_toolset.py create mode 100644 tests/tools/test_file_utils.py create mode 100644 tests/tools/test_function_tool.py create mode 100644 tests/tools/test_load_memory_tool.py create mode 100644 tests/tools/test_long_running_tool.py create mode 100644 tests/tools/test_mem0_tool.py create mode 100644 tests/tools/test_mempalace_tool.py create mode 100644 tests/tools/test_preload_memory_tool.py create mode 100644 tests/tools/test_registry.py create mode 100644 tests/tools/test_set_model_response_tool.py create mode 100644 tests/tools/test_streaming_function_tool.py create mode 100644 tests/tools/test_streaming_progress_tool.py create mode 100644 tests/tools/test_todo_tool.py create mode 100644 tests/tools/test_tool_adapter.py create mode 100644 tests/tools/test_transfer_to_agent_tool.py create mode 100644 tests/tools/test_websearch_tool.py create mode 100644 tests/tools/utils/__init__.py create mode 100644 tests/tools/utils/test_automatic_function_calling.py create mode 100644 tests/tools/utils/test_function_parameter_parse.py create mode 100644 tests/tools/utils/test_tool_utils.py create mode 100644 tests/trpc_agent_dsl/__init__.py create mode 100644 tests/trpc_agent_dsl/graph/__init__.py create mode 100644 tests/trpc_agent_dsl/graph/test_callbacks.py create mode 100644 tests/trpc_agent_dsl/graph/test_constants.py create mode 100644 tests/trpc_agent_dsl/graph/test_event_metadata.py create mode 100644 tests/trpc_agent_dsl/graph/test_event_writer.py create mode 100644 tests/trpc_agent_dsl/graph/test_events.py create mode 100644 tests/trpc_agent_dsl/graph/test_events_constants.py create mode 100644 tests/trpc_agent_dsl/graph/test_graph_agent.py create mode 100644 tests/trpc_agent_dsl/graph/test_interrupt.py create mode 100644 tests/trpc_agent_dsl/graph/test_memory_saver.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_action_agent.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_action_code.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_action_knowledge.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_action_llm.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_action_mcp.py create mode 100644 tests/trpc_agent_dsl/graph/test_node_config.py create mode 100644 tests/trpc_agent_dsl/graph/test_state.py create mode 100644 tests/trpc_agent_dsl/graph/test_state_graph.py create mode 100644 tests/trpc_agent_dsl/graph/test_state_mapper.py create mode 100644 tests/types/__init__.py create mode 100644 tests/types/test_agent_types.py create mode 100644 tests/types/test_event_actions.py create mode 100644 tests/types/test_instruction.py create mode 100644 tests/types/test_memory.py create mode 100644 tests/types/test_state.py create mode 100644 tests/types/test_ttl.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_context_utils.py create mode 100644 tests/utils/test_execute_cmd.py create mode 100644 tests/utils/test_hash_key.py create mode 100644 tests/utils/test_init.py create mode 100644 tests/utils/test_json_repair.py create mode 100644 tests/utils/test_registry_factory.py create mode 100644 tests/utils/test_registry_factory_ext.py create mode 100644 tests/utils/test_singleton.py create mode 100644 trpc_agent_sdk/__init__.py create mode 100644 trpc_agent_sdk/_cli.py create mode 100644 trpc_agent_sdk/abc/__init__.py create mode 100644 trpc_agent_sdk/abc/_agent.py create mode 100644 trpc_agent_sdk/abc/_artifact_service.py create mode 100644 trpc_agent_sdk/abc/_filter.py create mode 100644 trpc_agent_sdk/abc/_memory_service.py create mode 100644 trpc_agent_sdk/abc/_planner.py create mode 100644 trpc_agent_sdk/abc/_request.py create mode 100644 trpc_agent_sdk/abc/_response.py create mode 100644 trpc_agent_sdk/abc/_session.py create mode 100644 trpc_agent_sdk/abc/_session_service.py create mode 100644 trpc_agent_sdk/abc/_tool.py create mode 100644 trpc_agent_sdk/abc/_toolset.py create mode 100644 trpc_agent_sdk/agents/__init__.py create mode 100644 trpc_agent_sdk/agents/_base_agent.py create mode 100644 trpc_agent_sdk/agents/_callback.py create mode 100644 trpc_agent_sdk/agents/_chain_agent.py create mode 100644 trpc_agent_sdk/agents/_constants.py create mode 100644 trpc_agent_sdk/agents/_cycle_agent.py create mode 100644 trpc_agent_sdk/agents/_langgraph_agent.py create mode 100644 trpc_agent_sdk/agents/_llm_agent.py create mode 100644 trpc_agent_sdk/agents/_parallel_agent.py create mode 100644 trpc_agent_sdk/agents/_transfer_agent.py create mode 100644 trpc_agent_sdk/agents/core/README.md create mode 100644 trpc_agent_sdk/agents/core/__init__.py create mode 100644 trpc_agent_sdk/agents/core/_agent_transfer_processor.py create mode 100644 trpc_agent_sdk/agents/core/_code_execution_processor.py create mode 100644 trpc_agent_sdk/agents/core/_history_processor.py create mode 100644 trpc_agent_sdk/agents/core/_llm_processor.py create mode 100644 trpc_agent_sdk/agents/core/_output_schema_processor.py create mode 100644 trpc_agent_sdk/agents/core/_request_processor.py create mode 100644 trpc_agent_sdk/agents/core/_skill_processor.py create mode 100644 trpc_agent_sdk/agents/core/_skills_tool_result_processor.py create mode 100644 trpc_agent_sdk/agents/core/_tools_processor.py create mode 100644 trpc_agent_sdk/agents/core/_workspace_exec_processor.py create mode 100644 trpc_agent_sdk/agents/sub_agent/__init__.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_archetype.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_constants.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_defaults.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_description.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_loader.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_registry.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_runner.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py create mode 100644 trpc_agent_sdk/agents/utils/__init__.py create mode 100644 trpc_agent_sdk/agents/utils/_langgraph.py create mode 100644 trpc_agent_sdk/agents/utils/_langgraph_event_writer.py create mode 100644 trpc_agent_sdk/artifacts/__init__.py create mode 100644 trpc_agent_sdk/artifacts/_in_memory_artifact_service.py create mode 100644 trpc_agent_sdk/artifacts/_utils.py create mode 100644 trpc_agent_sdk/cancel/__init__.py create mode 100644 trpc_agent_sdk/cancel/_cancel.py create mode 100644 trpc_agent_sdk/cancel/_session_utils.py create mode 100644 trpc_agent_sdk/code_executors/__init__.py create mode 100644 trpc_agent_sdk/code_executors/_artifacts.py create mode 100644 trpc_agent_sdk/code_executors/_base_code_executor.py create mode 100644 trpc_agent_sdk/code_executors/_base_workspace_runtime.py create mode 100644 trpc_agent_sdk/code_executors/_code_executor_context.py create mode 100644 trpc_agent_sdk/code_executors/_constants.py create mode 100644 trpc_agent_sdk/code_executors/_program_session.py create mode 100644 trpc_agent_sdk/code_executors/_types.py create mode 100644 trpc_agent_sdk/code_executors/container/__init__.py create mode 100644 trpc_agent_sdk/code_executors/container/_container_cli.py create mode 100644 trpc_agent_sdk/code_executors/container/_container_code_executor.py create mode 100644 trpc_agent_sdk/code_executors/container/_container_ws_runtime.py create mode 100644 trpc_agent_sdk/code_executors/cube/__init__.py create mode 100644 trpc_agent_sdk/code_executors/cube/_code_executor.py create mode 100644 trpc_agent_sdk/code_executors/cube/_paths.py create mode 100644 trpc_agent_sdk/code_executors/cube/_runtime.py create mode 100644 trpc_agent_sdk/code_executors/cube/_sandbox.py create mode 100644 trpc_agent_sdk/code_executors/cube/_transfer.py create mode 100644 trpc_agent_sdk/code_executors/cube/_types.py create mode 100644 trpc_agent_sdk/code_executors/local/__init__.py create mode 100644 trpc_agent_sdk/code_executors/local/_local_program_session.py create mode 100644 trpc_agent_sdk/code_executors/local/_local_ws_runtime.py create mode 100644 trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py create mode 100644 trpc_agent_sdk/code_executors/utils/__init__.py create mode 100644 trpc_agent_sdk/code_executors/utils/_code_execution.py create mode 100644 trpc_agent_sdk/code_executors/utils/_files.py create mode 100644 trpc_agent_sdk/code_executors/utils/_meta.py create mode 100644 trpc_agent_sdk/code_executors/utils/_workspace.py create mode 100644 trpc_agent_sdk/common/__init__.py create mode 100644 trpc_agent_sdk/common/_compatible.py create mode 100644 trpc_agent_sdk/common/_constants.py create mode 100644 trpc_agent_sdk/configs/__init__.py create mode 100644 trpc_agent_sdk/configs/_model_retry_config.py create mode 100644 trpc_agent_sdk/configs/_prompt_cache_config.py create mode 100644 trpc_agent_sdk/configs/_run_config.py create mode 100644 trpc_agent_sdk/context/__init__.py create mode 100644 trpc_agent_sdk/context/_agent_context.py create mode 100644 trpc_agent_sdk/context/_common.py create mode 100644 trpc_agent_sdk/context/_constants.py create mode 100644 trpc_agent_sdk/context/_invocation_context.py create mode 100644 trpc_agent_sdk/dsl/__init__.py create mode 100644 trpc_agent_sdk/dsl/graph/__init__.py create mode 100644 trpc_agent_sdk/dsl/graph/_callbacks.py create mode 100644 trpc_agent_sdk/dsl/graph/_constants.py create mode 100644 trpc_agent_sdk/dsl/graph/_event_writer.py create mode 100644 trpc_agent_sdk/dsl/graph/_events/__init__.py create mode 100644 trpc_agent_sdk/dsl/graph/_events/_builder.py create mode 100644 trpc_agent_sdk/dsl/graph/_events/_constants.py create mode 100644 trpc_agent_sdk/dsl/graph/_events/_helpers.py create mode 100644 trpc_agent_sdk/dsl/graph/_events/_metadata.py create mode 100644 trpc_agent_sdk/dsl/graph/_graph_agent.py create mode 100644 trpc_agent_sdk/dsl/graph/_interrupt.py create mode 100644 trpc_agent_sdk/dsl/graph/_memory_saver.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/__init__.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_agent.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_base.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_code.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_knowledge.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_llm.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_action/_mcp.py create mode 100644 trpc_agent_sdk/dsl/graph/_node_config.py create mode 100644 trpc_agent_sdk/dsl/graph/_state.py create mode 100644 trpc_agent_sdk/dsl/graph/_state_graph.py create mode 100644 trpc_agent_sdk/dsl/graph/_state_mapper.py create mode 100644 trpc_agent_sdk/evaluation/__init__.py create mode 100644 trpc_agent_sdk/evaluation/_agent_evaluator.py create mode 100644 trpc_agent_sdk/evaluation/_agent_optimizer.py create mode 100644 trpc_agent_sdk/evaluation/_base_optimizer.py create mode 100644 trpc_agent_sdk/evaluation/_common.py create mode 100644 trpc_agent_sdk/evaluation/_criterion_registry.py create mode 100644 trpc_agent_sdk/evaluation/_eval_callbacks.py create mode 100644 trpc_agent_sdk/evaluation/_eval_case.py create mode 100644 trpc_agent_sdk/evaluation/_eval_config.py create mode 100644 trpc_agent_sdk/evaluation/_eval_criterion.py create mode 100644 trpc_agent_sdk/evaluation/_eval_metrics.py create mode 100644 trpc_agent_sdk/evaluation/_eval_pass.py create mode 100644 trpc_agent_sdk/evaluation/_eval_result.py create mode 100644 trpc_agent_sdk/evaluation/_eval_service_base.py create mode 100644 trpc_agent_sdk/evaluation/_eval_session_service.py create mode 100644 trpc_agent_sdk/evaluation/_eval_set.py create mode 100644 trpc_agent_sdk/evaluation/_eval_set_results_manager_base.py create mode 100644 trpc_agent_sdk/evaluation/_eval_set_results_manager_utils.py create mode 100644 trpc_agent_sdk/evaluation/_eval_sets_manager_base.py create mode 100644 trpc_agent_sdk/evaluation/_eval_sets_manager_utils.py create mode 100644 trpc_agent_sdk/evaluation/_evaluator_base.py create mode 100644 trpc_agent_sdk/evaluation/_evaluator_registry.py create mode 100644 trpc_agent_sdk/evaluation/_final_response_evaluator.py create mode 100644 trpc_agent_sdk/evaluation/_in_memory_eval_sets_manager.py create mode 100644 trpc_agent_sdk/evaluation/_llm_criterion.py create mode 100644 trpc_agent_sdk/evaluation/_llm_evaluator.py create mode 100644 trpc_agent_sdk/evaluation/_llm_judge.py create mode 100644 trpc_agent_sdk/evaluation/_local_eval_service.py create mode 100644 trpc_agent_sdk/evaluation/_local_eval_set_results_manager.py create mode 100644 trpc_agent_sdk/evaluation/_local_eval_sets_manager.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_config.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_evaluator_call.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_gepa_adapter.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_gepa_callback.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_gepa_reflective.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_metric_info.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_model_callable.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_model_options.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_registrations.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_registry.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_reporter.py create mode 100644 trpc_agent_sdk/evaluation/_optimize_result.py create mode 100644 trpc_agent_sdk/evaluation/_remote_eval_service.py create mode 100644 trpc_agent_sdk/evaluation/_rouge_evaluator.py create mode 100644 trpc_agent_sdk/evaluation/_static_user_simulator.py create mode 100644 trpc_agent_sdk/evaluation/_target_prompt.py create mode 100644 trpc_agent_sdk/evaluation/_trajectory_evaluator.py create mode 100644 trpc_agent_sdk/evaluation/_user_simulator_base.py create mode 100644 trpc_agent_sdk/evaluation/_user_simulator_provider.py create mode 100644 trpc_agent_sdk/evaluation/_utils.py create mode 100644 trpc_agent_sdk/events/__init__.py create mode 100644 trpc_agent_sdk/events/_agent_cancelled_event.py create mode 100644 trpc_agent_sdk/events/_cache_analyzer.py create mode 100644 trpc_agent_sdk/events/_event.py create mode 100644 trpc_agent_sdk/events/_event_translator.py create mode 100644 trpc_agent_sdk/events/_long_running_event.py create mode 100644 trpc_agent_sdk/events/_utils.py create mode 100644 trpc_agent_sdk/exceptions/__init__.py create mode 100644 trpc_agent_sdk/exceptions/_exceptions.py create mode 100644 trpc_agent_sdk/filter/__init__.py create mode 100644 trpc_agent_sdk/filter/_base_filter.py create mode 100644 trpc_agent_sdk/filter/_filter_runner.py create mode 100644 trpc_agent_sdk/filter/_registry.py create mode 100644 trpc_agent_sdk/filter/_run_filter.py create mode 100644 trpc_agent_sdk/knowledge/__init__.py create mode 100644 trpc_agent_sdk/knowledge/_filter_expr.py create mode 100644 trpc_agent_sdk/knowledge/_knowledge.py create mode 100644 trpc_agent_sdk/log/__init__.py create mode 100644 trpc_agent_sdk/log/_base_logger.py create mode 100644 trpc_agent_sdk/log/_default_logger.py create mode 100644 trpc_agent_sdk/log/_logger.py create mode 100644 trpc_agent_sdk/memory/__init__.py create mode 100644 trpc_agent_sdk/memory/_in_memory_memory_service.py create mode 100644 trpc_agent_sdk/memory/_redis_memory_service.py create mode 100644 trpc_agent_sdk/memory/_sql_memory_service.py create mode 100644 trpc_agent_sdk/memory/_utils.py create mode 100644 trpc_agent_sdk/memory/mem0_memory_service.py create mode 100644 trpc_agent_sdk/memory/mempalace_memory_service.py create mode 100644 trpc_agent_sdk/models/__init__.py create mode 100644 trpc_agent_sdk/models/_anthropic_model.py create mode 100644 trpc_agent_sdk/models/_constants.py create mode 100644 trpc_agent_sdk/models/_httpx_client.py create mode 100644 trpc_agent_sdk/models/_litellm_model.py create mode 100644 trpc_agent_sdk/models/_llm_model.py create mode 100644 trpc_agent_sdk/models/_llm_request.py create mode 100644 trpc_agent_sdk/models/_llm_response.py create mode 100644 trpc_agent_sdk/models/_openai_model.py create mode 100644 trpc_agent_sdk/models/_registry.py create mode 100644 trpc_agent_sdk/models/_retry.py create mode 100644 trpc_agent_sdk/models/openai_adapter/__init__.py create mode 100644 trpc_agent_sdk/models/openai_adapter/_base.py create mode 100644 trpc_agent_sdk/models/openai_adapter/_deepseek.py create mode 100644 trpc_agent_sdk/models/openai_adapter/_hunyuan.py create mode 100644 trpc_agent_sdk/models/tool_prompt/__init__.py create mode 100644 trpc_agent_sdk/models/tool_prompt/_base.py create mode 100644 trpc_agent_sdk/models/tool_prompt/_factory.py create mode 100644 trpc_agent_sdk/models/tool_prompt/_json.py create mode 100644 trpc_agent_sdk/models/tool_prompt/_xml.py create mode 100644 trpc_agent_sdk/planners/__init__.py create mode 100644 trpc_agent_sdk/planners/_built_in_planner.py create mode 100644 trpc_agent_sdk/planners/_plan_re_act_planner.py create mode 100644 trpc_agent_sdk/planners/_planning_processor.py create mode 100644 trpc_agent_sdk/runners.py create mode 100644 trpc_agent_sdk/server/__init__.py create mode 100644 trpc_agent_sdk/server/a2a/README.md create mode 100644 trpc_agent_sdk/server/a2a/__init__.py create mode 100644 trpc_agent_sdk/server/a2a/_agent_card_builder.py create mode 100644 trpc_agent_sdk/server/a2a/_agent_service.py create mode 100644 trpc_agent_sdk/server/a2a/_constants.py create mode 100644 trpc_agent_sdk/server/a2a/_remote_a2a_agent.py create mode 100644 trpc_agent_sdk/server/a2a/_utils.py create mode 100644 trpc_agent_sdk/server/a2a/converters/__init__.py create mode 100644 trpc_agent_sdk/server/a2a/converters/_event_converter.py create mode 100644 trpc_agent_sdk/server/a2a/converters/_part_converter.py create mode 100644 trpc_agent_sdk/server/a2a/converters/_request_converter.py create mode 100644 trpc_agent_sdk/server/a2a/executor/__init__.py create mode 100644 trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py create mode 100644 trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py create mode 100644 trpc_agent_sdk/server/a2a/logs/__init__.py create mode 100644 trpc_agent_sdk/server/a2a/logs/_log_utils.py create mode 100644 trpc_agent_sdk/server/ag_ui/README.md create mode 100644 trpc_agent_sdk/server/ag_ui/__init__.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/__init__.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_client_proxy_tool.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_client_proxy_toolset.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_converters.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_endpoint.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_event_translator.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_execution_state.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_feed_back_content.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_http_req.py create mode 100644 trpc_agent_sdk/server/ag_ui/_core/_session_manager.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/__init__.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/_langgraph_event_translator.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/_manager.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/_registry.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/_service.py create mode 100644 trpc_agent_sdk/server/ag_ui/_plugin/_utils.py create mode 100644 trpc_agent_sdk/server/agents/__init__.py create mode 100644 trpc_agent_sdk/server/agents/claude/__init__.py create mode 100644 trpc_agent_sdk/server/agents/claude/_claude_agent.py create mode 100644 trpc_agent_sdk/server/agents/claude/_proxy.py create mode 100644 trpc_agent_sdk/server/agents/claude/_proxy_logger.py create mode 100644 trpc_agent_sdk/server/agents/claude/_runtime.py create mode 100644 trpc_agent_sdk/server/agents/claude/_session_config.py create mode 100644 trpc_agent_sdk/server/agents/claude/_session_manager.py create mode 100644 trpc_agent_sdk/server/agents/claude/_setup.py create mode 100644 trpc_agent_sdk/server/knowledge/__init__.py create mode 100644 trpc_agent_sdk/server/knowledge/langchain_knowledge.py create mode 100644 trpc_agent_sdk/server/knowledge/tools/__init__.py create mode 100644 trpc_agent_sdk/server/knowledge/tools/langchain_knowledge_searchtool.py create mode 100644 trpc_agent_sdk/server/langfuse/__init__.py create mode 100644 trpc_agent_sdk/server/langfuse/prompt/__init__.py create mode 100644 trpc_agent_sdk/server/langfuse/prompt/_manager.py create mode 100644 trpc_agent_sdk/server/langfuse/tracing/__init__.py create mode 100644 trpc_agent_sdk/server/langfuse/tracing/opentelemetry.py create mode 100644 trpc_agent_sdk/server/openclaw/README.md create mode 100644 trpc_agent_sdk/server/openclaw/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/_cli.py create mode 100644 trpc_agent_sdk/server/openclaw/_logger.py create mode 100644 trpc_agent_sdk/server/openclaw/_utils.py create mode 100644 trpc_agent_sdk/server/openclaw/agent/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/agent/_agent.py create mode 100644 trpc_agent_sdk/server/openclaw/agent/_prompts.py create mode 100644 trpc_agent_sdk/server/openclaw/channels/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/channels/_command_handler.py create mode 100644 trpc_agent_sdk/server/openclaw/channels/_repair.py create mode 100644 trpc_agent_sdk/server/openclaw/channels/_telegram.py create mode 100644 trpc_agent_sdk/server/openclaw/channels/_wecom.py create mode 100644 trpc_agent_sdk/server/openclaw/claw.py create mode 100644 trpc_agent_sdk/server/openclaw/config.temp.yaml create mode 100644 trpc_agent_sdk/server/openclaw/config/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/config/_config.py create mode 100644 trpc_agent_sdk/server/openclaw/config/_constants.py create mode 100644 trpc_agent_sdk/server/openclaw/config_full.temp.yaml create mode 100644 trpc_agent_sdk/server/openclaw/image/telegram.png create mode 100644 trpc_agent_sdk/server/openclaw/image/wecom.png create mode 100644 trpc_agent_sdk/server/openclaw/metrics/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/metrics/_langfuse.py create mode 100644 trpc_agent_sdk/server/openclaw/metrics/_metrics.py create mode 100644 trpc_agent_sdk/server/openclaw/service/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/service/_heart_service.py create mode 100644 trpc_agent_sdk/server/openclaw/session_memory/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/session_memory/_claw_memory_service.py create mode 100644 trpc_agent_sdk/server/openclaw/session_memory/_claw_session_service.py create mode 100644 trpc_agent_sdk/server/openclaw/session_memory/_claw_summarizer.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/_deps.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/_skill_loader.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/_skill_parser.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/_skill_tool.py create mode 100644 trpc_agent_sdk/server/openclaw/skill/_utils.py create mode 100644 trpc_agent_sdk/server/openclaw/storage/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/storage/_aiofile_storage.py create mode 100644 trpc_agent_sdk/server/openclaw/storage/_constants.py create mode 100644 trpc_agent_sdk/server/openclaw/storage/_manager.py create mode 100644 trpc_agent_sdk/server/openclaw/storage/_utils.py create mode 100644 trpc_agent_sdk/server/openclaw/templates/ui.html create mode 100644 trpc_agent_sdk/server/openclaw/tools/__init__.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/cron.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/filesystem.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/mcp_tool.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/message.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/shell.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/spawn_task.py create mode 100644 trpc_agent_sdk/server/openclaw/tools/web.py create mode 100644 trpc_agent_sdk/server/openclaw/ui.py create mode 100644 trpc_agent_sdk/sessions/__init__.py create mode 100644 trpc_agent_sdk/sessions/_base_session_service.py create mode 100644 trpc_agent_sdk/sessions/_history_record.py create mode 100644 trpc_agent_sdk/sessions/_in_memory_session_service.py create mode 100644 trpc_agent_sdk/sessions/_redis_session_service.py create mode 100644 trpc_agent_sdk/sessions/_session.py create mode 100644 trpc_agent_sdk/sessions/_session_summarizer.py create mode 100644 trpc_agent_sdk/sessions/_sql_session_service.py create mode 100644 trpc_agent_sdk/sessions/_summarizer_checker.py create mode 100644 trpc_agent_sdk/sessions/_summarizer_manager.py create mode 100644 trpc_agent_sdk/sessions/_types.py create mode 100644 trpc_agent_sdk/sessions/_utils.py create mode 100644 trpc_agent_sdk/skills/__init__.py create mode 100644 trpc_agent_sdk/skills/_common.py create mode 100644 trpc_agent_sdk/skills/_constants.py create mode 100644 trpc_agent_sdk/skills/_dynamic_toolset.py create mode 100644 trpc_agent_sdk/skills/_hot_reload.py create mode 100644 trpc_agent_sdk/skills/_registry.py create mode 100644 trpc_agent_sdk/skills/_repository.py create mode 100644 trpc_agent_sdk/skills/_skill_config.py create mode 100644 trpc_agent_sdk/skills/_skill_profile.py create mode 100644 trpc_agent_sdk/skills/_state_keys.py create mode 100644 trpc_agent_sdk/skills/_state_migration.py create mode 100644 trpc_agent_sdk/skills/_state_order.py create mode 100644 trpc_agent_sdk/skills/_toolset.py create mode 100644 trpc_agent_sdk/skills/_types.py create mode 100644 trpc_agent_sdk/skills/_url_root.py create mode 100644 trpc_agent_sdk/skills/_utils.py create mode 100644 trpc_agent_sdk/skills/hub/__init__.py create mode 100644 trpc_agent_sdk/skills/hub/_claude_marketplace.py create mode 100644 trpc_agent_sdk/skills/hub/_clawhub.py create mode 100644 trpc_agent_sdk/skills/hub/_github.py create mode 100644 trpc_agent_sdk/skills/hub/_hermes_index.py create mode 100644 trpc_agent_sdk/skills/hub/_install.py create mode 100644 trpc_agent_sdk/skills/hub/_lobehub.py create mode 100644 trpc_agent_sdk/skills/hub/_skills_sh.py create mode 100644 trpc_agent_sdk/skills/hub/_source.py create mode 100644 trpc_agent_sdk/skills/hub/_types.py create mode 100644 trpc_agent_sdk/skills/hub/_well_known.py create mode 100644 trpc_agent_sdk/skills/stager/__init__.py create mode 100644 trpc_agent_sdk/skills/stager/_base_stager.py create mode 100644 trpc_agent_sdk/skills/stager/_types.py create mode 100644 trpc_agent_sdk/skills/stager/_utils.py create mode 100644 trpc_agent_sdk/skills/tools/__init__.py create mode 100644 trpc_agent_sdk/skills/tools/_common.py create mode 100644 trpc_agent_sdk/skills/tools/_file_stager.py create mode 100644 trpc_agent_sdk/skills/tools/_save_artifact.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_exec.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_list.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_list_docs.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_list_tool.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_load.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_run.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_select_docs.py create mode 100644 trpc_agent_sdk/skills/tools/_skill_select_tools.py create mode 100644 trpc_agent_sdk/skills/tools/_workspace_exec.py create mode 100644 trpc_agent_sdk/storage/__init__.py create mode 100644 trpc_agent_sdk/storage/_constants.py create mode 100644 trpc_agent_sdk/storage/_db.py create mode 100644 trpc_agent_sdk/storage/_redis.py create mode 100644 trpc_agent_sdk/storage/_sql.py create mode 100644 trpc_agent_sdk/storage/_sql_common.py create mode 100644 trpc_agent_sdk/teams/__init__.py create mode 100644 trpc_agent_sdk/teams/_team_agent.py create mode 100644 trpc_agent_sdk/teams/core/__init__.py create mode 100644 trpc_agent_sdk/teams/core/_delegation_signal.py create mode 100644 trpc_agent_sdk/teams/core/_delegation_tools.py create mode 100644 trpc_agent_sdk/teams/core/_member_message_filter.py create mode 100644 trpc_agent_sdk/teams/core/_message_builder.py create mode 100644 trpc_agent_sdk/teams/core/_system_message.py create mode 100644 trpc_agent_sdk/teams/core/_team_run_context.py create mode 100644 trpc_agent_sdk/telemetry/__init__.py create mode 100644 trpc_agent_sdk/telemetry/_custom_metrics.py create mode 100644 trpc_agent_sdk/telemetry/_custom_trace.py create mode 100644 trpc_agent_sdk/telemetry/_metrics.py create mode 100644 trpc_agent_sdk/telemetry/_trace.py create mode 100644 trpc_agent_sdk/tools/__init__.py create mode 100644 trpc_agent_sdk/tools/_agent_tool.py create mode 100644 trpc_agent_sdk/tools/_base_tool.py create mode 100644 trpc_agent_sdk/tools/_constants.py create mode 100644 trpc_agent_sdk/tools/_context_var.py create mode 100644 trpc_agent_sdk/tools/_default_toolset.py create mode 100644 trpc_agent_sdk/tools/_function_tool.py create mode 100644 trpc_agent_sdk/tools/_load_memory_tool.py create mode 100644 trpc_agent_sdk/tools/_long_running_tool.py create mode 100644 trpc_agent_sdk/tools/_preload_memory_tool.py create mode 100644 trpc_agent_sdk/tools/_registry.py create mode 100644 trpc_agent_sdk/tools/_set_model_response_tool.py create mode 100644 trpc_agent_sdk/tools/_streaming_function_tool.py create mode 100644 trpc_agent_sdk/tools/_streaming_progress_tool.py create mode 100644 trpc_agent_sdk/tools/_todo_tool.py create mode 100644 trpc_agent_sdk/tools/_tool_adapter.py create mode 100644 trpc_agent_sdk/tools/_transfer_to_agent_tool.py create mode 100644 trpc_agent_sdk/tools/_webfetch_tool.py create mode 100644 trpc_agent_sdk/tools/_websearch_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/__init__.py create mode 100644 trpc_agent_sdk/tools/file_tools/_bash_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/_edit_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/_file_toolset.py create mode 100644 trpc_agent_sdk/tools/file_tools/_file_utils.py create mode 100644 trpc_agent_sdk/tools/file_tools/_glob_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/_grep_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/_read_tool.py create mode 100644 trpc_agent_sdk/tools/file_tools/_write_tool.py create mode 100644 trpc_agent_sdk/tools/goal_tools/__init__.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_base.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_goal_create_tool.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_goal_get_tool.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_goal_toolset.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_goal_update_tool.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_helpers.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_lock.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_models.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_prompt.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_setup.py create mode 100644 trpc_agent_sdk/tools/goal_tools/_store.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/__init__.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/_mcp_session_manager.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/_mcp_tool.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/_mcp_toolset.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/_types.py create mode 100644 trpc_agent_sdk/tools/mcp_tool/_utils.py create mode 100644 trpc_agent_sdk/tools/mem0_tool.py create mode 100644 trpc_agent_sdk/tools/mempalace_tool.py create mode 100644 trpc_agent_sdk/tools/task_tools/__init__.py create mode 100644 trpc_agent_sdk/tools/task_tools/_base.py create mode 100644 trpc_agent_sdk/tools/task_tools/_helpers.py create mode 100644 trpc_agent_sdk/tools/task_tools/_lock.py create mode 100644 trpc_agent_sdk/tools/task_tools/_models.py create mode 100644 trpc_agent_sdk/tools/task_tools/_prompt.py create mode 100644 trpc_agent_sdk/tools/task_tools/_store.py create mode 100644 trpc_agent_sdk/tools/task_tools/_task_create_tool.py create mode 100644 trpc_agent_sdk/tools/task_tools/_task_get_tool.py create mode 100644 trpc_agent_sdk/tools/task_tools/_task_list_tool.py create mode 100644 trpc_agent_sdk/tools/task_tools/_task_toolset.py create mode 100644 trpc_agent_sdk/tools/task_tools/_task_update_tool.py create mode 100644 trpc_agent_sdk/tools/task_tools/_validators.py create mode 100644 trpc_agent_sdk/tools/utils/__init__.py create mode 100644 trpc_agent_sdk/tools/utils/_automatic_function_calling.py create mode 100644 trpc_agent_sdk/tools/utils/_function_parameter_parse.py create mode 100644 trpc_agent_sdk/tools/utils/_tool_utils.py create mode 100644 trpc_agent_sdk/types/__init__.py create mode 100644 trpc_agent_sdk/types/_agent_types.py create mode 100644 trpc_agent_sdk/types/_event_actions.py create mode 100644 trpc_agent_sdk/types/_instruction.py create mode 100644 trpc_agent_sdk/types/_memory.py create mode 100644 trpc_agent_sdk/types/_state.py create mode 100644 trpc_agent_sdk/types/_ttl.py create mode 100644 trpc_agent_sdk/types/_usage.py create mode 100644 trpc_agent_sdk/utils/__init__.py create mode 100644 trpc_agent_sdk/utils/_context_utils.py create mode 100644 trpc_agent_sdk/utils/_execute_cmd.py create mode 100644 trpc_agent_sdk/utils/_hash_key.py create mode 100644 trpc_agent_sdk/utils/_json_repair.py create mode 100644 trpc_agent_sdk/utils/_registry_factory.py create mode 100644 trpc_agent_sdk/utils/_singleton.py create mode 100644 trpc_agent_sdk/version.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..38985f927 --- /dev/null +++ b/.coveragerc @@ -0,0 +1 @@ +[run] diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..e0688a379 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +ignore = E402, W503 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..c8fcd6e49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '0 17 * * *' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + pull-requests: read + +jobs: + lint: + runs-on: [self-hosted, trpc-agent-python-ci] + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get changed Python files + id: changed + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + FILES=$(git diff --name-only --diff-filter=ACM origin/${{ github.base_ref }}...HEAD -- '*.py' | grep '^trpc_agent_sdk/' || true) + else + FILES=$(git diff --name-only --diff-filter=ACM HEAD~1...HEAD -- '*.py' | grep '^trpc_agent_sdk/' || true) + fi + 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 + + test: + runs-on: [self-hosted, trpc-agent-python-ci] + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install package and dependencies + run: | + pip install -r requirements-test.txt + + - name: Run tests with coverage + # run: pytest --cov=trpc_agent_sdk --cov-report=xml --cov-report=term tests/ + run: pytest --cov=trpc_agent_sdk --cov-report=xml --cov-report=term --cov-fail-under=80 tests/ + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + file: coverage.xml + + build: + runs-on: [self-hosted, trpc-agent-python-ci] + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build package + run: | + pip install build + python -m build + + - name: Verify package + run: | + pip install dist/*.whl + python -c "import trpc_agent_sdk; print('Import OK')" diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 000000000..9b611a1fe --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,31 @@ +name: "CLA Assistant" +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, synchronize, reopened] + +# explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +jobs: + CLAAssistant: + runs-on: ubuntu-latest + steps: + - name: "CLA Assistant" + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' + uses: contributor-assistant/github-action@v2.3.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_DATABASE_ACCESS_TOKEN }} + with: + remote-organization-name: trpc-group + remote-repository-name: cla-database + path-to-signatures: 'signatures/${{ github.event.repository.name }}-${{ github.repository_id }}/cla.json' + path-to-document: 'https://github.com/trpc-group/cla-database/blob/main/Tencent-Contributor-License-Agreement.md' + # branch should not be protected + branch: 'main' diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..91a6736fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +.idea +.vscode +.DS_Store +*.lock +*.log +examples/*.log + +trpc-agent-py.egg-info + +build +dist +.venv +venv +venv* +__pycache__ + +.coverage +htmlcov +cov.tmp +coverage.xml +test-ngtest-ut-trpc-agent-py.xml +.pytest_cache + +node_modules +package-lock.json +pyrightconfig.json + +# SDD-managed scratch (added by subagent-driven-development) +.superpowers/sdd/ + +# Claude Code per-machine state +.claude/ + +# Eval-optimize loop runtime artifacts +examples/optimization/eval_optimize_loop/output/ +examples/optimization/eval_optimize_loop/**/__pycache__/ diff --git a/.yapfignore b/.yapfignore new file mode 100644 index 000000000..47e14cd19 --- /dev/null +++ b/.yapfignore @@ -0,0 +1,25 @@ +# 构建和分发目录 +build/ +dist/ +*.egg-info/ + +# 虚拟环境 +venv/ +env/ +.venv/ +.env/ + +# Python 缓存 +__pycache__/ +*.pyc +*.pyo + +# 文档 +docs/ + +# 临时文件 +*.tmp +*.temp + +# exapmle +examples/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..6b1bb00ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +## [1.1.11](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.11) (2026-06-26) + +### Features + +* Model: Added SDK-managed model retry support for `OpenAIModel`, `AnthropicModel`, and `LiteLLMModel`, including `ModelRetryConfig`, `ExponentialBackoffConfig`, provider-aware retry decisions from headers / HTTP status / SDK exceptions, `Retry-After` and `retry-after-ms` handling, and full-jitter exponential backoff. Streaming retries are guarded so the SDK only replays a model call before any user-visible content has been emitted, avoiding duplicated partial text or tool calls. +* Model: Refactored HTTP client lifecycle management around `http_client_provider_factory`, adding explicit temporary and shared HTTP client providers plus `close_shared_http_clients()` so callers can choose per-request clients by default or opt in to connection reuse with bounded `httpx.AsyncClient` pooling. OpenAI and Anthropic model tests and documentation were updated to cover provider-owned client injection and cleanup behavior. +* Tools: Added a Claude Code-style `TodoWriteTool` that lets agents maintain a structured todo list in branch-scoped session state, with validation for complete-list replacement, unique items, and at most one `in_progress` item. Added examples for normal todo usage and human-in-the-loop todo workflows. +* Tools: Added `TaskToolSet` with `task_create`, `task_update`, `task_get`, and `task_list` tools, providing persistent structured task boards with server-assigned task ids, status updates, dependency edges, and single-in-progress enforcement. Added task tool examples and unit coverage for task lifecycle behavior. +* Skill: Added `LinkSkillStager` and renamed the file-system stager module from copy-oriented naming to file-oriented naming, allowing skills to be staged into workspaces through links while preserving the shared workspace directories required by code execution and skill artifacts. +* Skill: Added cached filesystem skill repositories via `CachedFsSkillRepository` and `use_cached_repository=True` in `create_default_skill_repository()`, caching `SKILL.md` front matter and body by file signature to reduce repeated skill scanning and loading overhead while still invalidating entries when files change or are deleted. +* Code Execution: Extended workspace staging and runtime metadata to support link-mode staging, explicit workspace stage options, TTY flags, and `work/inputs` layout initialization so skill-provided files can be prepared before skill loading and code execution steps run. +* Examples/Docs: Added runnable examples and documentation for model retry, todo tools, task tools, shared HTTP client configuration, skill link staging, cached skill repositories, and tool usage updates across English and Chinese docs. + +### Bug Fixes + +* Model: Fixed loss of normal assistant text when a streaming OpenAI-compatible response contains both text and a tool call. The final non-partial response now keeps user-visible text while still converting parsed tool calls into structured `function_call` parts, preventing text that appeared in the stream from being dropped from session history and later model context. +* Model: Updated LiteLLM retry and error handling so normalized LiteLLM exception headers and status codes participate in the same retry decisions as OpenAI and Anthropic, and so failures after partial streaming output are surfaced as final errors instead of replayed. +* Skill: Ensured workspace layout creates `work/inputs` up front, avoiding races where code or skill commands attempt to copy input files before `skill_load` has linked or initialized the input directory. +* Telemetry: Updated model metrics reporting to align with the retry wrapper and renamed metric attributes so model calls report consistent retry-aware execution data. + +## [1.1.10](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.10) (2026-06-18) + +### Bug Fixes + +* Model: fix error about pickle of OpenAIModel. + + +## [1.1.9](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.9) (2026-06-18) + +### Features + +* Model: Added `http_client_factory` support to `OpenAIModel`, allowing callers to inject a custom `httpx.AsyncClient` factory to control HTTP connection lifecycle and pool settings such as keepalive expiry (#83). + +### Bug Fixes + +* Telemetry: Switched `agent_run` and `invocation` spans back to `start_as_current_span` so child spans such as `call_llm` inherit the correct parent context, restoring complete trace attributes (including system instructions and tools) in Langfuse reporting. + +## [1.1.8](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.8) (2026-06-12) + +### Features + +* Session: Reworked session history storage from `Event.model_flags`-based model visibility to an active/historical split, with `Session.events` holding the active model window and `Session.historical_events` optionally retaining events moved out by max-event filtering, TTL, or summarization. +* Session: Added `SessionServiceConfig.store_historical_events`, updated Redis, SQL, and InMemory persistence semantics for active/historical events, and kept list APIs lightweight by omitting both active and historical events from `list_sessions()`. +* Session: Optimized summarization by keeping `[summary_event, recent_events...]` as the new active window and checking only the leading summary anchor instead of repeatedly scanning the event list. +* Model: Added configuration support for OpenAI/Anthropic APIs and LiteLLM prompt cache. + +### Bug Fixes + +* Telemetry: Propagated span context correctly in async generators by using `start_span` with context attach/detach, and fixed member-agent input tracing to prefer `override_messages` over `user_content`. + +## [1.1.7](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.7) (2026-06-05) + +### Bug Fixes + +* Runner: Added `close_session_service_on_close` and `close_memory_service_on_close` controls so short-lived runners can skip closing externally managed session and memory services, such as shared Redis-backed services. +* MCP: Updated Streamable HTTP session creation to prefer the non-deprecated `streamable_http_client` API, with fallback support for older MCP SDKs that only expose `streamablehttp_client`. +* MCP: Moved Streamable HTTP headers and timeout configuration onto an owned `httpx.AsyncClient`, avoiding deprecated transport arguments while keeping the HTTP client lifecycle tied to the MCP session context. +* Storage: Fixed frequent sqlite warnings in `SqlSessionService` by consistently using database-side `func.now()` for update timestamps. + + +## [1.1.6](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.6) (2026-06-03) + +### Features + +* Skill: Added a recoverable Cube sandbox runtime for skills, including `CubeClientConfig`, a unified `create_cube_sandbox_client` entry point, optional `auto_recover` support in `CubeSandboxClient`, sandbox lifecycle helpers, and direct `CubeWorkspaceRuntime` creation from the client. +* Skill: Unified skill load/run/exec/stager paths around repository-level workspace runtime resolution via `repository.get_workspace_runtime(ctx)`, so tools under the same skill repository share one workspace runtime context. +* MCP: Added MCP tool caching to avoid repeated network access. +* Tools: Added `GraphAgent` support in `AgentTool`, allowing wrapped graph agents to return results from tool context state. +* Examples/Eval: Restored evaluation examples that were previously removed during open-source cleanup. +* Optimizer: Added support for the prompt self-optimization `AgentOptimizer`. + +### Bug Fixes + +* Storage: Fixed frequent sqlite warnings in `SqlSessionService` by consistently using database-side `func.now()` for update timestamps. + +## [1.1.5](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.5) (2026-05-19) + +### Features + +* Tools: Added `StreamingProgressTool` with matching `ToolsProcessor` plumbing so tools can surface intermediate progress as `partial=True` events while still emitting a single final `function_response`; included `BaseTool` streaming hooks, the `llmagent_with_streaming_progress_tool` example and verification script. +* Eval: Added `RemoteEvalService` to drive evaluations against agents exposed over remote interfaces, refactored `AgentEvaluator` to support remote agent calls, and expanded English/Chinese evaluation docs. +* Model: Landed the OpenAI-compatible adapter layer (`models/openai_adapter/{_base,_deepseek,_hunyuan}.py`) that isolates provider-specific behavior from `OpenAIModel`, including DeepSeek v4 thinking / `response_format` / `reasoning_content` / token usage handling and hy3-preview ToolPrompt text parsing with streaming filter. +* Examples: Added `examples/mempalace_mcp` (MemPalace via MCP) and updated `examples/llmagent_with_thinking` to enable `add_tools_to_prompt` only for hy3-preview and display thinking / tool calls / final answer separately. + +* Utils: Added `json_loads_repair` and `json_repair_string` helpers (backed by `json_repair`) under `trpc_agent_sdk.utils`, with full unit test coverage. +* Model/Tools: Adopted `json_repair` only on JSON-tolerant paths — `JsonToolPrompt` / `XmlToolPrompt` parse_function, non-streaming OpenAI tool-call args, `AgentTool` structured-output validation, skills tool result parsing — while keeping strict `json.loads` for the streaming tool-call accumulator (to preserve "wait for next chunk" semantics) and Hunyuan plain-text `` parsing (to avoid silently coercing plain text into empty strings). +* Model: Fixed ToolPrompt streaming parsing so multiple tool calls in a single response are all preserved instead of only the last one being kept. + +### Bug Fixes + +* Teams: TeamAgent now honors `actions.skip_summarization` from custom tool events, so tools like `AgentTool(skip_summarization=True)` and `StreamingProgressTool(skip_summarization=True)` end the leader loop without an extra summarization turn (previously masked by leader's `disable_react_tool=True`). + +## [1.1.4](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.4) (2026-05-13) + +### Bug Fixes + +* Tools: Removed default `mempalace_tool` exports from `trpc_agent_sdk.tools` to avoid forcing MemPalace optional dependencies during base package import. + +## [1.1.3](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.3) (2026-05-12) + +### Features + +* Model: Added an OpenAI-compatible adapter layer to isolate provider-specific behaviors, including DeepSeek v4 reasoning/format handling and hy3-preview tool-prompt parsing support. +* Memory: Added MemPalace integration with `MemPalaceMemoryService` and `mempalace_tool`, plus related examples and documentation. +* Code Execution: Added Cube/E2B sandbox executor and workspace runtime with optional dependency support and end-to-end example coverage. +* Eval: Added support for evaluating the same metric across different LLMs. + +### Bug Fixes + +* Model: Fixed ToolPrompt streaming parsing so multiple tool calls in one response are preserved instead of only the last call. +* Storage: Improved SQL storage compatibility by filtering empty content parts, fixing MySQL `DynamicPickleType` serialization, and stabilizing session timestamp updates. +* Eval: Fixed judge-agent JSON output handling in the eval module. +* CI: Added missing `e2b-code-interpreter` test dependency to prevent cube test collection failures. + +## [1.1.2.post1](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.2.post1) (2026-04-29) + +### Features + +* Session: Updated session summarization to retain full conversation history while marking summarized events as model-invisible. +* Session: Added backend-threaded summarization execution to avoid blocking front-end conversation turns. +* Skill: Added multi-user support for skill operations. + +### Bug Fixes + +* Code Execution: Fixed the conflict between code execution and tool invocation where tool data could be lost after code execution. +* MCP: Added support for parsing and returning multiple MCP tool results in a single response. + +## [1.1.2](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.2) (2026-04-24) + +### Features + +* Telemetry: Added OpenTelemetry metrics reporting and introduced `custom_metrics` to support framework metric reporting when parsing remote agent responses. +* Tools: Added `web_search` with DuckDuckGo and Google providers, and added `web_fetch` for webpage content retrieval. +* Docs/Examples: Added usage documentation and examples for `web_search` and `web_fetch`. + +### Bug Fixes + +* Teams: Fixed parallel delegation signal loss and enabled streaming output in team delegation flows. + +## [1.1.1](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.1) (2026-04-20) + +### Features + +* Storage: Added the `usage_metadata` field to SQL storage and introduced automatic migration for missing columns. +* Skill: Added cross-session skill state persistence so loaded skills can be reused across sessions, reducing repeated skill loading and unnecessary retry turns. +* Skill: Added skill install/uninstall awareness so the model can detect skill lifecycle changes and avoid missing-skill lookups or calls to uninstalled skills. +* Session: Added `usage_metadata` support in SQL session storage for persisting and reading token usage statistics. + +### Bug Fixes + +* Skill: Reduced hallucinated skill command generation when users intend to run commands, lowering invalid command attempts and retry loops. + + +## [1.1.0](https://github.com/trpc-group/trpc-agent-python/releases/tag/v1.1.0) (2026-04-07) + +### Features + +* Unified Agent framework with `LlmAgent`, `LangGraphAgent` and `TransferAgent` +* Multi-agent orchestration with built-in `Chain`, `Parallel`, and `Cycle` patterns, plus Team and nested Team collaboration +* Human-in-the-loop workflows with pause, review, and resume support for long-running tasks +* Rich tool ecosystem including built-in file/shell tools, MCP tools, LangChain tools, and extensible third-party integrations +* Extensible Skill system with local and HTTP distribution, dynamic loading, timeout control, and sandbox execution +* Code execution support with async runtime and sandbox/container execution options +* Session and memory services with in-memory, Redis, and SQL backends, including filtering, summarization, and scheduled cleanup +* RAG and knowledge capabilities through `LangchainKnowledge` with loaders, splitters, embedders, vector stores, retrievers, and prompt templates +* Evaluation framework with trajectory and response quality assessment, LLM-judge metrics, parallel evaluation, and JSON reporting +* Service and protocol integrations for A2A, AG-UI, and OpenClaw runtime scenarios +* OpenClaw runtime capabilities for gateway/chat/ui/deps workflows with pluggable channels, tools, skills, session and memory integration +* OpenClaw skill dependency management with profile-based inspection and install planning for common runtime environments +* Observability via tracing support, including end-to-end execution flow, tool-call traces, and cancellation traces +* Developer experience support with practical examples and DebugServer for local development and validation diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 000000000..e5d84164b --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,50 @@ +# Community Code of Conduct + +Welcome to our open source project! + +We are committed to creating a friendly, respectful, and inclusive community. +To ensure a positive experience in our project, we have established the following code of conduct, which we require all participants to abide by, and provide a safe and inclusive environment for all community members. + +## Our Pledge + +As participants, contributors, and maintainers of our community, we pledge to: +- Treat everyone with openness, inclusivity, and collaboration; +- Respect individuals with different backgrounds and viewpoints, regardless of gender, sexual orientation, disability, race, ethnicity, religion, age, or any other factor; +- Focus on contributing and improving the project, rather than attacking or criticizing individuals; +- Build trust with community members and promote our project through constructive feedback; +- Provide a safe, supportive, and encouraging environment for community members to promote learning and personal growth. + +## Our standards + +Our community members should adhere to the following standards: +- Respect the opinions, viewpoints, and experiences of others; +- Avoid using insulting, discriminatory, or harmful language; +- Do not harass, intimidate, or threaten others; +- Do not publicly or privately disclose others' private information, such as contact information or addresses; +- Respect the privacy of others; +- Establish a safe, inclusive, and respectful environment for community members. + +## Our responsibility + +Project maintainers have a responsibility to create a friendly, respectful, and inclusive environment for our community members. +They should: +- Clearly and publicly explain the community guidelines; +- Handle reports of guideline violations and resolve disputes appropriately; +- Protect the privacy and security of all community members; +- Maintain a fair, transparent, and responsible attitude. + + +## Scope + +This code of conduct applies to all project spaces, including GitHub, mailing lists, forums, social media, gatherings, and conferences. +Violations of the code of conduct will be dealt with, including but not limited to warnings, temporary or permanent bans, revocation of contribution rights, and revocation of project access rights. + +## Implementation guidelines + +If you encounter behavior that violates this code of conduct, you can: +- Communicate privately with the relevant person to try to resolve the issue; +- Report the violation to the project maintainers(maintainer mailing list), who will take necessary action based on the situation; +- If you are not satisfied with the way the maintainers handle the situation, you can seek help from higher-level organizations or institutions. + +Our community is a diverse, open, and inclusive community, and we welcome everyone's participation and contribution. +We believe that only in a safe, respectful, and inclusive environment can we create the best project together. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..37aac1403 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# How to Contribute + +Thank you for your interest and support in tRPC-Agent-Python! + +We welcome and appreciate any form of contribution, including but not limited to submitting issues, providing improvement suggestions, improving documentation, fixing bugs, and adding features. +This document aims to provide you with a detailed contribution guide to help you better participate in the project. +Please read this guide carefully before contributing and make sure to follow the rules here. +We look forward to working with you to make this project better together! + +## Before contributing code + +The project welcomes code patches, but to make sure things are well coordinated you should discuss any significant change before starting the work. +It's recommended that you signal your intention to contribute in the issue tracker, either by claiming an [existing one](https://github.com/trpc-group/trpc-agent-python/issues) or by [opening a new issue](https://github.com/trpc-group/trpc-agent-python/issues/new). + +### Checking the issue tracker + +Whether you already know what contribution to make, or you are searching for an idea, the [issue tracker](https://github.com/trpc-group/trpc-agent-python/issues) is always the first place to go. +Issues are triaged to categorize them and manage the workflow. + +Most issues will be marked with one of the following workflow labels: +- **NeedsInvestigation**: The issue is not fully understood and requires analysis to understand the root cause. +- **NeedsDecision**: The issue is relatively well understood, but the tRPC-Agent-Python team hasn't yet decided the best way to address it. + It would be better to wait for a decision before writing code. + If you are interested in working on an issue in this state, feel free to "ping" maintainers in the issue's comments if some time has passed without a decision. +- **NeedsFix**: The issue is fully understood and code can be written to fix it. + +### Opening an issue for any new problem + +Excluding very trivial changes, all contributions should be connected to an existing issue. +Feel free to open one and discuss your plans. +This process gives everyone a chance to validate the design, helps prevent duplication of effort, and ensures that the idea fits inside the goals for the language and tools. +It also checks that the design is sound before code is written; the code review tool is not the place for high-level discussions. + +When opening an issue, make sure to answer these five questions: +1. What version of tRPC-Agent-Python are you using? +2. What operating system and Python version are you using (`python -V`)? +3. What did you do? +4. What did you expect to see? +5. What did you see instead? + +For change proposals, see Proposing Changes To [tRPC-Proposals](https://github.com/trpc-group/trpc/tree/main/proposal). + +## Contributing code + +Follow the [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow) to [create a GitHub pull request](https://docs.github.com/en/get-started/quickstart/github-flow#create-a-pull-request). +If this is your first time submitting a PR to the tRPC-Agent-Python project, you will be reminded in the "Conversation" tab of the PR to sign and submit the [Contributor License Agreement](https://github.com/trpc-group/cla-database/blob/main/Tencent-Contributor-License-Agreement.md). +Only when you have signed the Contributor License Agreement, your submitted PR has the possibility of being accepted. + +Some things to keep in mind: +- Ensure that your code conforms to the project's code specifications. + This includes but is not limited to code style, comment specifications, etc. This helps us to maintain the cleanliness and consistency of the project. +- Before submitting a PR, please make sure that you have tested your code locally (`pytest`). + Ensure that the code has no obvious errors and can run normally. +- To update the pull request with new code, just push it to the branch; + you can either add more commits, or rebase and force-push (both styles are accepted). +- If the request is accepted, all commits will be squashed, and the final commit description will be composed by concatenating the pull request's title and description. + The individual commits' descriptions will be discarded. + See following "Write good commit messages" for some suggestions. + +### Writing good commit messages + +Commit messages in tRPC-Agent-Python follow a specific set of conventions, which we discuss in this section. + +Here is an example of a good one: + +> agent: improve streaming tool event handling stability +> +> The existing implementation may produce inconsistent event ordering +> in rare network conditions, so we improve buffering and flush behavior +> to ensure deterministic output sequence. +> +> Fixes #159 +> +> RELEASE NOTES: Improved stability of streaming tool event handling. + +#### First line + +The first line of the change description is conventionally a short one-line summary of the change, prefixed by the primary affected package. + +A rule of thumb is that it should be written so to complete the sentence "This change modifies tRPC-Agent-Python to _____." +That means it does not start with a capital letter, is not a complete sentence, and actually summarizes the result of the change. + +Follow the first line by a blank line. + +#### Main content + +The rest of the description elaborates and should provide context for the change and explain what it does. +Write in complete sentences with correct punctuation, just like for your comments in tRPC-Agent-Python. +Don't use HTML, Markdown, or any other markup language. +Add any relevant information, such as benchmark data if the change affects performance. + +#### Referencing issues + +The special notation "Fixes #12345" associates the change with issue 12345 in the tRPC-Agent-Python issue tracker. +When this change is eventually applied, the issue tracker will automatically mark the issue as fixed. + +- If there is a corresponding issue, add either `Fixes #12345` or `Updates #12345` (the latter if this is not a complete fix) to this comment. +- If referring to a repo other than `trpc-agent-python`, you can use the `owner/repo#issue_number` syntax: `Fixes trpc-group/tnet#12345`. + +#### PR type label + +The PR type label is used to help identify the types of changes going into the release over time. This may allow the Release Team to develop a better understanding of what sorts of issues we would miss with a faster release cadence. + +For all pull requests, one of the following PR type labels must be set: + +- type/bug: Fixes a newly discovered bug. +- type/enhancement: Adding tests, refactoring. +- type/feature: New functionality. +- type/documentation: Adds documentation. +- type/api-change: Adds, removes, or changes an API. +- type/failing-test: CI test case is showing intermittent failures. +- type/performance: Changes that improves performance. +- type/ci: Changes the CI configuration files and scripts. + +#### Release notes + +Release notes are required for any pull request with user-visible changes, this could mean: + +- User facing, critical bug-fixes +- Notable feature additions +- Deprecations or removals +- API changes +- Documents additions + +If the current PR doesn't have user-visible changes, such as internal code refactoring or adding test cases, the release notes should be filled with "NONE" and the changes in this PR will not be recorded in the next version's CHANGELOG. If the current PR has user-visible changes, the release notes should be filled out according to the actual situation, avoiding technical details and describing the impact of the current changes from a user's perspective as much as possible. + +Release notes are one of the most important reference points for users about to import or upgrade to a particular release of tRPC-Agent-Python. + +## Miscellaneous topics + +### Copyright headers + +Files in the tRPC-Agent-Python repository don't list author names, both to avoid clutter and to avoid having to keep the lists up to date. +Instead, your name will appear in the change log. + +New files that you contribute should use the standard copyright header: + +```python +# +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2025 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +``` + +Files in the repository are copyrighted the year they are added. +Do not update the copyright year on files that you change. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 000000000..c95330277 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,316 @@ +# Installation Guide + +## Overview + +| Item | Details | +| ---------- | -------------------------------------------------------------------- | +| Name | tRPC-Agent-Python (`trpc-agent-py`) | +| Version | **1.1.0** | +| Description | A production-grade agent framework developed by Tencent, supporting multiple model providers (including OpenAI, Anthropic, DeepSeek, and LiteLLM). It provides tool-calling capabilities, multi-agent orchestration, session and long-term memory management, RAG-based knowledge, and seamless deployment as a service via FastAPI. | +| License | Apache-2.0 | +| Repository | https://github.com/trpc-group/trpc-agent-python | + +## Platforms + +| Operating System | Support Status | +| -------------------------- | -------------- | +| Linux (Ubuntu/CentOS/Debian) | ✅ Fully supported (recommended for production) | +| macOS (Intel / Apple Silicon) | ✅ Fully supported (recommended for development) | + +--- + +## Dependencies + +### Required Dependencies + +| Dependency | Version | Description | Download URL | +| ------------- | -------------- | --------------------------------- | ------------------------------------------------ | +| **Python** | 3.12 | Runtime environment | https://www.python.org/downloads/ | +| **pip** | >= 21.0 | Python package manager | Bundled with Python, upgrade via `pip install --upgrade pip` | +| **git** | >= 2.0 | Required for source code installation | https://git-scm.com/downloads | + +### Core Dependencies + +The core dependencies are `automatically installed` when installing `trpc-agent-py`, the complete list of core dependencies please refer to `requirements.txt` ([./requirements.txt](./requirements.txt)) file. + +### Optional Dependencies + +| Dependency | Purpose | Installation | +| ------------- | --------------------------------- | ----------------------------------------------- | +| **Docker** | CodeExecutor containerized execution | https://docs.docker.com/get-docker/ | +| **Redis** | Redis session/memory | https://redis.io/download | +| **MySQL** | SQL session | https://dev.mysql.com/downloads/ | + + +> **Tip**: It is recommended to use [pyenv](https://github.com/pyenv/pyenv) or [conda](https://docs.conda.io/) to manage Python versions and avoid conflicts with the system Python. + +--- + +## Installation + +### Pip Installation + +```bash +# Create a virtual environment +python3 -m venv .venv + +# Activate the virtual environment +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows + +pip install trpc-agent-py +``` + +Install optional extensions: + +```bash +# Choose as needed, multiple extensions can be combined with commas +pip install "trpc-agent-py[a2a,knowledge,agent-claude]" +``` + +--- + +### Source Code Installation + +```bash +# Clone the repository +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +# Create and activate a virtual environment +python3 -m venv .venv +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows +# Install +pip install -e . +``` + +### Optional Dependencies Reference + +| Extension | Purpose | Install Command | +| ---------------- | ----------------------------- | --------------------------------------- | +| `a2a` | Google A2A protocol | `pip install "trpc-agent-py[a2a]"` | +| `ag-ui` | AG-UI protocol | `pip install "trpc-agent-py[ag-ui]"` | +| `agent-claude` | Claude Agent | `pip install "trpc-agent-py[agent-claude]"` | +| `knowledge` | Knowledge base / RAG | `pip install "trpc-agent-py[knowledge]"` | +| `mem0` | Long-term memory (Mem0) | `pip install "trpc-agent-py[mem0]"` | +| `langchain_tool` | LangChain Tool integration | `pip install "trpc-agent-py[langchain_tool]"` | +| `langfuse` | Langfuse observability | `pip install "trpc-agent-py[langfuse]"` | +| `eval` | Evaluation framework | `pip install "trpc-agent-py[eval]"` | +| `openclaw` | OpenClaw integration | `pip install "trpc-agent-py[openclaw]"` | +| `dev` | Development (lint/format/test)| `pip install "trpc-agent-py[dev]"` | +| `all` | All optional dependencies | `pip install "trpc-agent-py[all]"` | + +--- + +## Configuration + +### Environment Variables + +trpc-agent-py uses environment variables to configure model connections. There are two ways to set them: + +**Option 1**: Create a `.env` file in the project directory (recommended) + +```bash +# .env file contents +# Model API key +TRPC_AGENT_API_KEY="your-api-key" + +# Model service URL +TRPC_AGENT_BASE_URL="your-base-url" + +# Default model name +TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +> **Note**: Do not commit the `.env` file to version control. Make sure `.gitignore` includes `.env`. + +**Option 2**: Export directly to the shell environment + +```bash +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +### Configuration File Locations + +| File | Location | Description | +| ----------------- | --------- | ----------------------------------- | +| `.env` | Project root / example subdirectories | Environment variable config (templates available in each example directory) | +| `pyproject.toml` | Project root | Build config, tool config (yapf/pytest, etc.) | + +--- + +## Verification + +### Check Framework Version + +```bash +python -c "from trpc_agent_sdk.version import __version__; print(f'trpc-agent-py {__version__}')" +``` + +### Check Core Module Imports + +```bash +python -c " +from trpc_agent_sdk.agents import LlmAgent, ChainAgent, ParallelAgent, TransferAgent +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.models import OpenAIModel +print('All core modules imported successfully.') +" +``` + +Expected output: +``` +All core modules imported successfully. +``` +### Run Unit Tests + +```bash +# Install test dependencies +pip install -r requirements-test.txt +# Run all tests +pytest tests/ -v +``` + +## Troubleshooting + +### Pip install is slow or times out + +**Problem**: Installation hangs or reports `ReadTimeoutError`. + +**Solution**: Use a mirror to speed up downloads. + +```bash +# Temporary usage Tencent Cloud mirror +pip install trpc-agent-py -i https://mirrors.cloud.tencent.com/pypi/simple + +# Set global Tencent Cloud mirror +pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple +``` + +Other available mirrors: +| Mirror | URL | +| -------- | ------------------------------------------- | +| Tencent Cloud | https://mirrors.cloud.tencent.com/pypi/simple | +| Tsinghua | https://pypi.tuna.tsinghua.edu.cn/simple | +| Alibaba Cloud | https://mirrors.aliyun.com/pypi/simple/ | + +--- + +### Python version does not meet requirements + +**Problem**: +``` +ERROR: Package 'trpc-agent-py' requires a different Python: 3.9.x not in '>=3.10' +``` + +**Solution**: Upgrade to Python 3.12. + +```bash +# Solution 1: Using pyenv to install Python 3.12 +pyenv install 3.12 +pyenv local 3.12 + +# Solution 2: Using conda to create a virtual environment and activate it +conda create -n trpc-agent-py python=3.12 +conda activate trpc-agent-py +``` + +Verify the version: +```bash +python3 --version +``` + +--- + +### Permission denied + +**Problem**: +``` +ERROR: Could not install packages due to an EnvironmentError: [Errno 13] Permission denied +``` + +**Solution**: + +```bash +# Recommended: use a virtual environment to avoid permission issues +python3 -m venv .venv +source .venv/bin/activate +pip install trpc-agent-py +``` + +> **Note**: It is not recommended to use `sudo pip install` as it may corrupt the system Python environment. + +--- + +### Model call error: TRPC_AGENT_API_KEY must be set + +**Problem**: Reports that the API key is not set or is empty. + +**Solution**: + +```bash +# Check if the environment variable is set +echo $TRPC_AGENT_API_KEY + +# If empty, set the environment variables +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +If using a `.env` file, make sure dotenv is loaded in your code: +```python +from dotenv import load_dotenv +load_dotenv() +``` + +--- + +### ImportError: No module named 'xxx' + +**Problem**: `ModuleNotFoundError` when importing an extension module. + +**Reason**: The corresponding optional dependency is not installed. + +**Solution**: Install the matching extension based on the missing module. + +| Missing Module | Install Command | +| ----------------------- | ------------------------------------------- | +| `a2a_sdk` | `pip install "trpc-agent-py[a2a]"` | +| `ag_ui_protocol` | `pip install "trpc-agent-py[ag-ui]"` | +| `claude_agent_sdk` | `pip install "trpc-agent-py[agent-claude]"` | +| `langchain_community` | `pip install "trpc-agent-py[knowledge]"` | +| `mem0ai` | `pip install "trpc-agent-py[mem0]"` | +| `langchain_tavily` | `pip install "trpc-agent-py[langchain_tool]"` | + +--- + +### Pydantic version conflict + +**Problem**: +If your environment contains packages pinned to Pydantic v1, you may encounter errors such as: +```text +pydantic.errors.PydanticImportError: `BaseSettings` has been moved to the `pydantic-settings` package +``` +Or other Pydantic v1/v2 compatibility errors. + +**Solution**: This framework requires Pydantic v2 (>= 2.11.3). + +```bash +pip install --upgrade pydantic>=2.11.3 +``` + +If other packages depend on Pydantic v1, use an isolated virtual environment. + +--- + +## Next Step + +- **Quick Start**: Check out [examples/quickstart/](./examples/quickstart/) to run your first Agent quickly +- **Full Documentation**: Visit the [docs/mkdocs/en/](./docs/mkdocs/en/) +- **More Examples**: Browse the [examples/](./examples/) directory, covering multi-agent, tool calling, knowledge, memory, service deployment, etc. +- **Contributing**: Read [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/INSTALL.zh_CN.md b/INSTALL.zh_CN.md new file mode 100644 index 000000000..063275482 --- /dev/null +++ b/INSTALL.zh_CN.md @@ -0,0 +1,318 @@ +# 安装文档 + +## 项目简介 + +| 项目 | 信息 | +| ---------- | -------------------------------------------------------------------- | +| 名称 | tRPC-Agent-Python (`trpc-agent-py`) | +| 版本 | **1.1.0** | +| 描述 | 腾讯开源的生产级 Agent 框架,支持多模型(OpenAI / Anthropic / DeepSeek / LiteLLM)、工具调用、多 Agent 编排、会话与长期记忆、知识库(RAG)、FastAPI 服务部署 | +| 许可证 | Apache-2.0 | +| 仓库地址 | https://github.com/trpc-group/trpc-agent-python | + +## 支持平台 + +| 操作系统 | 支持情况 | +| ------------------ | -------- | +| Linux (Ubuntu/CentOS/Debian) | ✅ 完全支持(推荐生产环境) | +| macOS (Intel / Apple Silicon) | ✅ 完全支持(推荐开发环境) | + +--- + +## 安装依赖 + +### 基础依赖 + +| 依赖 | 版本要求 | 说明 | 下载地址 | +| ------------- | -------------- | --------------------------------- | ------------------------------------------------ | +| **Python** | 3.12 | 运行时环境 | https://www.python.org/downloads/ | +| **pip** | >= 21.0 | Python 包管理器 | Python 自带,可以通过`pip install --upgrade pip` 升级 | +| **git** | >= 2.0 | 采用源码安装时必须 | https://git-scm.com/downloads | + +### 主要依赖 + +安装 `trpc-agent-py` 时主要依赖会`自动安装`,完整的主要依赖列表请参考 `requirements.txt` ([./requirements.txt](./requirements.txt)) 文件。 + +### 系统级可选依赖 + +| 依赖 | 用途 | 安装方式 | +| ------------- | --------------------------------- | ----------------------------------------------- | +| **Docker** | CodeExecutor 容器化代码执行 | https://docs.docker.com/get-docker/ | +| **Redis** | Redis 会话/记忆后端 | https://redis.io/download | +| **MySQL** | SQL 会话后端 | https://dev.mysql.com/downloads/ | + + +> **提示**:建议使用 [pyenv](https://github.com/pyenv/pyenv) 或 [conda](https://docs.conda.io/) 管理 Python 版本,避免与系统 Python 冲突。 + +--- + +## 安装步骤 + +### Pip 安装 + +```bash +# 创建虚拟环境 +python3 -m venv .venv + +# 激活虚拟环境 +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows + +pip install trpc-agent-py +``` + +安装扩展功能(可选): + +```bash +# 按需选择,多个扩展可用逗号组合 +pip install "trpc-agent-py[a2a,knowledge,agent-claude]" +``` + +--- + +### 源码安装 + +```bash +# 克隆仓库 +git clone https://github.com/trpc-group/trpc-agent-python.git +cd trpc-agent-python +# 创建并激活虚拟环境 +python3 -m venv .venv +source .venv/bin/activate # Linux / macOS +# .venv\Scripts\activate # Windows +# 安装 +pip install -e . +``` + +### 可选依赖对照表 + +| 扩展名 | 用途 | 安装命令 | +| ---------------- | ----------------------------- | --------------------------------------- | +| `a2a` | Google A2A 协议 | `pip install "trpc-agent-py[a2a]"` | +| `ag-ui` | AG-UI 协议 | `pip install "trpc-agent-py[ag-ui]"` | +| `agent-claude` | Claude Agent | `pip install "trpc-agent-py[agent-claude]"` | +| `knowledge` | 知识库 / RAG | `pip install "trpc-agent-py[knowledge]"` | +| `mem0` | 长期记忆(Mem0) | `pip install "trpc-agent-py[mem0]"` | +| `langchain_tool` | LangChain Tool 集成 | `pip install "trpc-agent-py[langchain_tool]"` | +| `langfuse` | Langfuse 可观测性 | `pip install "trpc-agent-py[langfuse]"` | +| `eval` | 评测框架 | `pip install "trpc-agent-py[eval]"` | +| `openclaw` | OpenClaw 集成 | `pip install "trpc-agent-py[openclaw]"` | +| `dev` | 开发环境(lint/格式化/测试) | `pip install "trpc-agent-py[dev]"` | +| `all` | 所有可选依赖 | `pip install "trpc-agent-py[all]"` | + +--- + +## 配置说明 + +### 环境变量配置 + +trpc-agent-py 框架通过环境变量配置模型连接信息。有两种配置方式: + +**方式 1**:在项目目录下创建 `.env` 文件(推荐) + +```bash +# .env 文件内容 +# 模型 API 密钥 +TRPC_AGENT_API_KEY="your-api-key" + +# 模型服务地址 +TRPC_AGENT_BASE_URL="your-base-url" + +#模型名称 +TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +> **注意**:请勿将 `.env` 文件提交到版本控制中。确保 `.gitignore` 中包含 `.env`。 + +**方式 2**:直接导出到 Shell 环境 + +```bash +# 导出环境变量 +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +### 配置文件位置 + +| 文件 | 位置 | 说明 | +| ----------------- | --------- | ----------------------------------- | +| `.env` | 项目根目录 / example 子目录 | 环境变量配置(各 example 目录下有模板) | +| `pyproject.toml` | 项目根目录 | 构建配置、工具配置(yapf/pytest 等) | + +--- + +## 验证安装 + +### 检查框架版本 + +```bash +python -c "from trpc_agent_sdk.version import __version__; print(f'trpc-agent-py {__version__}')" +``` + +### 检查框架核心模块 + +```bash +python -c " +from trpc_agent_sdk.agents import LlmAgent, ChainAgent, ParallelAgent, TransferAgent +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.models import OpenAIModel +print('All core modules imported successfully.') +" +``` + +预期输出: +``` +All core modules imported successfully. +``` + +### 运行单元测试 + +```bash +# 安装测试依赖 +pip install -r requirements-test.txt +# 运行全部测试 +pytest tests/ -v +``` + +## 常见问题与解决方案 + +### Pip install 速度慢或超时 + +**问题**:安装过程长时间无响应或报 `ReadTimeoutError`。 + +**解决方案**:使用国内镜像源加速。 + +```bash +# 临时使用 +pip install trpc-agent-py -i https://mirrors.cloud.tencent.com/pypi/simple + +# 设置全局镜像 +pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple +``` + +其他可用镜像源: +| 镜像名 | 地址 | +| -------- | ------------------------------------------- | +| 腾讯云 | https://mirrors.cloud.tencent.com/pypi/simple | +| 清华 | https://pypi.tuna.tsinghua.edu.cn/simple | +| 阿里云 | https://mirrors.aliyun.com/pypi/simple/ | + +--- + +### Python 版本不满足要求 + +**问题**: +``` +ERROR: Package 'trpc-agent-py' requires a different Python: 3.9.x not in '>=3.10' +``` + +**解决方案**:升级到 Python 3.12。 + +```bash +# 解决方案 1: 使用 pyenv 安装 +pyenv install 3.12 +pyenv local 3.12 + +# 解决方案 2: 使用 conda 创建虚拟环境并激活 +conda create -n trpc-agent-py python=3.12 +conda activate trpc-agent-py +``` + +验证版本: +```bash +python3 --version +``` + +--- + +### 权限不足 + +**问题**: +``` +ERROR: Could not install packages due to an EnvironmentError: [Errno 13] Permission denied +``` + +**解决方案**: + +```bash +# 推荐:使用虚拟环境(避免权限问题) +python3 -m venv .venv +source .venv/bin/activate +pip install trpc-agent-py +``` + +> **注意**:不推荐使用 `sudo pip install`,可能导致系统 Python 环境混乱。 + +--- + +### 模型调用报错 TRPC_AGENT_API_KEY must be set + +**问题**:API Key 未设置或为空 + +**解决方案**: + +```bash +# 检查环境变量是否已设置 +echo $TRPC_AGENT_API_KEY + +# 如果为空,设置环境变量 +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +如果使用 `.env` 文件,请确保代码中加载了 dotenv: +```python +from dotenv import load_dotenv +load_dotenv() +``` + +--- + +### ImportError: No module named 'xxx' + +**问题**:导入某个扩展模块时报 `ModuleNotFoundError`。 + +**原因**:未安装对应的可选依赖。 + +**解决方案**:根据报错模块安装对应扩展。 + +| 缺失模块 | 安装命令 | +| ----------------------- | ------------------------------------------- | +| `a2a_sdk` | `pip install "trpc-agent-py[a2a]"` | +| `ag_ui_protocol` | `pip install "trpc-agent-py[ag-ui]"` | +| `claude_agent_sdk` | `pip install "trpc-agent-py[agent-claude]"` | +| `langchain_community` | `pip install "trpc-agent-py[knowledge]"` | +| `mem0ai` | `pip install "trpc-agent-py[mem0]"` | +| `langchain_tavily` | `pip install "trpc-agent-py[langchain_tool]"` | + +--- + +### Pydantic 版本冲突 + +**问题**: +如果环境包含 pinned 到 Pydantic v1 的包,可能会遇到以下错误: +``` +pydantic.errors.PydanticImportError: `BaseSettings` has been moved to the `pydantic-settings` package +``` +或其他 Pydantic v1/v2 兼容性错误。 + +**解决方案**:本框架要求 Pydantic v2(>= 2.11.3)。 + +```bash +pip install --upgrade pydantic>=2.11.3 +``` + +如果其他包依赖 Pydantic v1,建议使用隔离的虚拟环境。 + +--- + +## 下一步 + +- **快速开始**:查看 [examples/quickstart/](./examples/quickstart/),快速跑通第一个 Agent +- **完整文档**:访问 [docs/mkdocs/zh/](./docs/mkdocs/zh/) +- **更多示例**:浏览 [examples/](./examples/) 目录,涵盖多 Agent 编排、工具调用、知识库、服务部署等场景 +- **参与贡献**:阅读 [CONTRIBUTING.md](./CONTRIBUTING.md) diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..15db63fdf --- /dev/null +++ b/LICENSE @@ -0,0 +1,135 @@ +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, except for the third-party components listed below, which remain licensed under their respective original terms. tRPC-Agent-Python does not impose any additional restrictions beyond those specified in the original licenses of these third-party components. Users are required to comply with all applicable terms and conditions of the original licenses and to ensure that the use of these third-party components conforms to all relevant laws and regulations. + +In case you believe there have been errors in the attribution below, you may submit the concerns to us for review and correction. + +Terms of the Apache-2.0 License: +-------------------------------------------------------------------- +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + + + +Dependencies and Licenses: + + +The below software in this distribution may have been modified by Tencent ("Tencent Modifications"). All Tencent Modifications +are Copyright(C)Tencent. + +Open Source Software Licensed under the Apache-2.0: +-------------------------------------------------------------------- +1. adk +Copyright 2025 Google LLC + +2. agno +Copyright 2025-2026 Agno Inc. + +-------------------------------------------------------------------- +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +================================================== +End of the Attribution Notice of this project. + diff --git a/README.md b/README.md new file mode 100644 index 000000000..279e21698 --- /dev/null +++ b/README.md @@ -0,0 +1,611 @@ +[English](README.md) | [中文](README.zh_CN.md) + +# tRPC-Agent-Python + +[![PyPI Version](https://img.shields.io/pypi/v/trpc-agent-py.svg)](https://pypi.org/project/trpc-agent-py/) +[![Python Versions](https://img.shields.io/pypi/pyversions/trpc-agent-py.svg)](https://pypi.org/project/trpc-agent-py/) +[![LICENSE](https://img.shields.io/badge/license-Apache--2.0-green.svg)](https://github.com/trpc-group/trpc-agent-python/blob/main/LICENSE) +[![Releases](https://img.shields.io/github/release/trpc-group/trpc-agent-python.svg?style=flat-square)](https://github.com/trpc-group/trpc-agent-python/releases) +[![Coverage](https://codecov.io/gh/trpc-group/trpc-agent-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/trpc-group/trpc-agent-python/tree/main) +[![Documentation](https://img.shields.io/badge/Docs-Website-blue.svg)](https://trpc-group.github.io/trpc-agent-python/) + +**A production-grade Agent framework deeply integrated with the Python AI ecosystem.** +tRPC-Agent-Python provides an end-to-end foundation for agent building, orchestration, tool integration, session and long-term memory, service deployment, and observability, so you can ship reliable and extensible AI applications faster. + +## Why Choose tRPC-Agent-Python + +- **Multi-paradigm agent orchestration**: Built-in orchestration supports `ChainAgent` / `ParallelAgent` / `CycleAgent` / `TransferAgent`, with `GraphAgent` for graph-based orchestration. +- **Graph orchestration capability (`GraphAgent`)**: Use DSL to orchestrate `Agent` / `Tool` / `MCP` / `Knowledge` / `CodeExecutor` in one unified flow. +- **Efficient integration with Python AI ecosystems**: Agent ecosystem extensions (`claude-agent-sdk` / `LangGraph`, etc.) / Tool ecosystem extensions (`mcp`, etc.) / Knowledge ecosystem extensions (`LangChain`, etc.) / Model ecosystem extensions (`LiteLLM`, etc.) / Memory ecosystem extensions (`Mem0`, `Mempalace`, etc.). +- **Agent ecosystem extensions**: Supports `LangGraphAgent` / `ClaudeAgent` / `TeamAgent` (Agno-Like). +- **Tool ecosystem extensions**: `FunctionTool` / File tools / `MCPToolset` / LangChain Tool / Agent-as-Tool. +- **Complete memory capability (`Session` / `Memory`)**: `Session` manages messages and state within a single session, while `Memory` manages cross-session long-term memory and personalization. Persistence supports `InMemory` / `Redis` / `SQL`; `Memory` also supports `Mem0`、`Mempalace`. +- **Production-grade knowledge capability**: Built on LangChain components with first-class RAG support. +- **CodeExecutor extension capability**: Supports local / container executors for code execution and task grounding. +- **Skills extension capability**: Supports `SKILL.md`-based skill systems for reusable capabilities and dynamic tooling. +- **Connect to multiple LLM providers**: OpenAI-like / Anthropic / LiteLLM routing. +- **Serving and observability**: Expose HTTP / A2A / AG-UI services through FastAPI, with built-in OpenTelemetry tracing. +- **trpc-claw (OpenClaw-like personal agent)**: Built on [nanobot](https://github.com/HKUDS/nanobot), tRPC-Agent ships trpc-claw so you can quickly build an OpenClaw-like personal AI agent with Telegram, WeCom, and other channel support. + +## Use Cases + +- Intelligent customer support and knowledge QA (RAG + session memory) +- Code generation and engineering automation (`ClaudeAgent`) +- Code execution and automated task grounding (`CodeExecutor`) +- Agent Skills for reusable capabilities +- Multi-role collaborative workflows (`TeamAgent` / multi-agent) +- Cross-protocol agent service integration (`A2A` / `AG-UI`) +- MCP tool protocol integration and tool ecosystem expansion +- Unified gateway access and protocol conversion +- Component-based workflow orchestration using `GraphAgent` +- Reusing existing LangGraph workflows in this runtime +- Build an OpenClaw-like personal AI agent quickly with trpc-claw + +## Table of Contents + +- [tRPC-Agent-Python](#trpc-agent-python) + - [Why Choose tRPC-Agent-Python](#why-choose-trpc-agent-python) + - [Use Cases](#use-cases) + - [Table of Contents](#table-of-contents) + - [Quick Start](#quick-start) + - [trpc-claw Usage](#trpc-claw-usage) + - [Documentation](#documentation) + - [Examples](#examples) + - [1. Getting Started and Basic Agents](#1-getting-started-and-basic-agents) + - [2. Preset Multi-Agent Orchestration](#2-preset-multi-agent-orchestration) + - [3. Team Collaboration](#3-team-collaboration) + - [4. Graph Orchestration](#4-graph-orchestration) + - [5. Agent Ecosystem Extensions](#5-agent-ecosystem-extensions) + - [6. Tools and MCP](#6-tools-and-mcp) + - [7. Skills](#7-skills) + - [8. CodeExecutor](#8-codeexecutor) + - [9. Session, Memory, and Knowledge](#9-session-memory-and-knowledge) + - [10. Serving and Protocols](#10-serving-and-protocols) + - [11. Filters and Execution Control](#11-filters-and-execution-control) + - [12. Advanced LlmAgent Capabilities](#12-advanced-llmagent-capabilities) + - [13. LlmAgent Tool Calling and Interaction](#13-llmagent-tool-calling-and-interaction) + - [Architecture Overview](#architecture-overview) + - [Contributing](#contributing) + - [Acknowledgements](#acknowledgements) + +## Quick Start + +### Prerequisites + +- Python 3.10+ (Python 3.12 recommended) +- Available model API key (OpenAI-like / Anthropic, or route via LiteLLM) + +### Installation + +```bash +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] +``` + +### Develop Weather Agent + +```python +import asyncio +import os +import uuid + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import Content, Part + + +async def get_weather_report(city: str) -> dict: + return {"city": city, "temperature": "25°C", "condition": "Sunny", "humidity": "60%"} + + +async def main(): + model = OpenAIModel( + model_name=os.environ["TRPC_AGENT_MODEL_NAME"], + api_key=os.environ["TRPC_AGENT_API_KEY"], + base_url=os.environ.get("TRPC_AGENT_BASE_URL", ""), + ) + + agent = LlmAgent( + name="assistant", + description="A helpful assistant", + model=model, + instruction="You are a helpful assistant.", + tools=[FunctionTool(get_weather_report)], + ) + + session_service = InMemorySessionService() + runner = Runner(app_name="demo_app", agent=agent, session_service=session_service) + + user_id = "demo_user" + session_id = str(uuid.uuid4()) + user_content = Content(parts=[Part.from_text(text="What's the weather in Beijing?")]) + + async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content): + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + if part.text and event.partial: + print(part.text, end="", flush=True) + elif part.function_call: + print(f"\n🔧 [{part.function_call.name}({part.function_call.args})]", flush=True) + elif part.function_response: + print(f"📊 [{part.function_response.response}]", flush=True) + + print() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Run the Agent + +```bash +export TRPC_AGENT_API_KEY=xxx +export TRPC_AGENT_BASE_URL=xxxx +export TRPC_AGENT_MODEL_NAME=xxxx +python quickstart.py +``` + +## trpc-claw Usage + +tRPC-Agent ships trpc-claw (`trpc_agent_cmd openclaw`), built on [nanobot](https://github.com/HKUDS/nanobot), so you can quickly build an OpenClaw-like personal AI agent. Start it with a single command and it runs 24/7 — chat through Telegram, WeCom, or any other IM, or use it locally via CLI / UI. + +For full configuration and advanced features, see: [openclaw.md](./docs/mkdocs/en/openclaw.md) + +### Quick Start + +**1. Generate config** + +```bash +mkdir -p ~/.trpc_claw +trpc_agent_cmd openclaw conf_temp > ~/.trpc_claw/config.yaml +``` + +**2. Set environment variables** + +```bash +export TRPC_AGENT_API_KEY=your_api_key +export TRPC_AGENT_BASE_URL=your_base_url +export TRPC_AGENT_MODEL_NAME=your_model +``` + +**3. Run locally** + +```bash +# Force local CLI mode +trpc_agent_cmd openclaw chat -c ~/.trpc_claw/config.yaml + +# Local UI +trpc_agent_cmd openclaw ui -c ~/.trpc_claw/config.yaml +``` + +**4. Connect WeCom / Telegram** + +Enable the channel in `config.yaml`, then launch with `run`: + +```yaml +channels: + wecom: + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + # or Telegram: + # telegram: + # enabled: true + # token: ${TELEGRAM_BOT_TOKEN} +``` + +```bash +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config.yaml +``` + +If no channel is available, `run` automatically falls back to local CLI for easy debugging. + +## Documentation + +- See directory: [`docs/mkdocs/en`](./docs/mkdocs/en) + +## Examples + +All examples in the `examples` directory are runnable. The groups below organize recommended starting points by capability, with short guidance so you can quickly pick what to read first for your scenario. + +### 1. Getting Started and Basic Agents + +Recommended first: + +- [examples/quickstart](./examples/quickstart/README.md) - Minimal runnable demo +- [examples/llmagent](./examples/llmagent/README.md) - Basic `LlmAgent` usage +- [examples/litellm](./examples/litellm/README.md) - LiteLLM backend routing with `LiteLLMModel` +- [examples/llmagent_with_custom_prompt](./examples/llmagent_with_custom_prompt/README.md) - Custom prompts +- [examples/llmagent_with_schema](./examples/llmagent_with_schema/README.md) - Structured outputs + +Related docs: [llm_agent.md](./docs/mkdocs/en/llm_agent.md) / [model.md](./docs/mkdocs/en/model.md) + +This group helps you: + +- Run a full end-to-end path from user input to tool call to model output +- Understand how to consume `function_call` / `function_response` events in streaming output +- Learn baseline patterns for prompts and structured responses + +Start with this snippet (`Runner` + streaming events): + +```python +runner = Runner(app_name=app_name, agent=root_agent, session_service=session_service) +async for event in runner.run_async(user_id=user_id, session_id=current_session_id, new_message=user_content): + if event.partial and event.content: + ... +``` + +### 2. Preset Multi-Agent Orchestration + +Recommended first: + +- [examples/multi_agent_chain](./examples/multi_agent_chain/README.md) - `ChainAgent` +- [examples/multi_agent_parallel](./examples/multi_agent_parallel/README.md) - `ParallelAgent` +- [examples/multi_agent_cycle](./examples/multi_agent_cycle/README.md) - `CycleAgent` +- [examples/transfer_agent](./examples/transfer_agent/README.md) - `TransferAgent` handoff +- [examples/multi_agent_subagent](./examples/multi_agent_subagent/README.md) - Sub-agent delegation +- [examples/multi_agent_compose](./examples/multi_agent_compose/README.md) - Composed orchestration +- [examples/multi_agent_start_from_last](./examples/multi_agent_start_from_last/README.md) - Resume from last agent state + +Related docs: [multi_agents.md](./docs/mkdocs/en/multi_agents.md) + +This group helps you: + +- Understand the role differences among Chain / Parallel / Cycle / Transfer +- Pick serial, parallel, loop, or handoff orchestration by task shape +- Learn how to resume and compose flows from existing outputs + +Start with this snippet (`ChainAgent`): + +```python +pipeline = ChainAgent( + name="document_processor", + sub_agents=[extractor_agent, translator_agent], +) +``` + +### 3. Team Collaboration + +Recommended first: + +- [examples/team](./examples/team/README.md) - Team coordination mode +- [examples/team_parallel_execution](./examples/team_parallel_execution/README.md) - Team parallel execution +- [examples/team_with_skill](./examples/team_with_skill/README.md) - Team + Skills +- [examples/team_human_in_the_loop](./examples/team_human_in_the_loop/README.md) - Team with human-in-the-loop +- [examples/team_as_sub_agent](./examples/team_as_sub_agent/README.md) - Team as a sub-agent +- [examples/team_member_message_filter](./examples/team_member_message_filter/README.md) - Team member message filtering +- [examples/team_member_agent_claude](./examples/team_member_agent_claude/README.md) - Team member using `ClaudeAgent` +- [examples/team_member_agent_langgraph](./examples/team_member_agent_langgraph/README.md) - Team member using `LangGraphAgent` +- [examples/team_member_agent_team](./examples/team_member_agent_team/README.md) - Nested Team members +- [examples/team_with_cancel](./examples/team_with_cancel/README.md) - Team task cancellation + +Related docs: [team.md](./docs/mkdocs/en/team.md) / [human_in_the_loop.md](./docs/mkdocs/en/human_in_the_loop.md) / [cancel.md](./docs/mkdocs/en/cancel.md) + +This group helps you: + +- Understand the Leader / Member collaboration model in Team +- Combine Skills, sub-teams, and external agents in one workflow +- Cover practical concerns like filtering, human approval, and cancellation + +Start with this snippet (`TeamAgent`): + +```python +content_team = TeamAgent( + name="content_team", + model=model, + members=[researcher, writer], + instruction=LEADER_INSTRUCTION, + share_member_interactions=True, +) +``` + +### 4. Graph Orchestration + +Recommended first: + +- [examples/graph](./examples/graph/README.md) - `GraphAgent` with function / llm / agent / code / mcp / knowledge nodes +- [examples/graph_multi_turns](./examples/graph_multi_turns/README.md) - Multi-turn graph execution +- [examples/graph_with_interrupt](./examples/graph_with_interrupt/README.md) - Graph execution interruption +- [examples/dsl](./examples/dsl/README.md) - DSL orchestration basics +- [examples/dsl/classifier_mcp](./examples/dsl/classifier_mcp/README.md) - DSL + MCP classification routing + +Related docs: [graph.md](./docs/mkdocs/en/graph.md) / [dsl.md](./docs/mkdocs/en/dsl.md) + +This group helps you: + +- Build explicit, controllable workflows (branching, merging, interruption, resuming) +- Mix `Agent` / `Tool` / `MCP` / `CodeExecutor` / `Knowledge` in a single graph +- Use DSL for workflows that stay readable and maintainable + +Start with this snippet (conditional routing): + +```python +graph.add_conditional_edges( + "decide", + create_route_choice(set(path_map.keys())), + path_map, +) +``` + +### 5. Agent Ecosystem Extensions + +Recommended first: + +- [examples/langgraph_agent](./examples/langgraph_agent/README.md) - Integrate pre-built and compiled LangGraph workflows +- [examples/langgraph_agent_with_cancel](./examples/langgraph_agent_with_cancel/README.md) - `LangGraphAgent` cancellation +- [examples/langgraphagent_with_human_in_the_loop](./examples/langgraphagent_with_human_in_the_loop/README.md) - `LangGraphAgent` human-in-the-loop +- [examples/claude_agent](./examples/claude_agent/README.md) - `ClaudeAgent` basics +- [examples/claude_agent_with_streaming_tool](./examples/claude_agent_with_streaming_tool/README.md) - `ClaudeAgent` streaming tools +- [examples/claude_agent_with_skills](./examples/claude_agent_with_skills/README.md) - `ClaudeAgent` + Skills +- [examples/claude_agent_with_code_writer](./examples/claude_agent_with_code_writer/README.md) - `ClaudeAgent` for code generation +- [examples/claude_agent_with_travel_planner](./examples/claude_agent_with_travel_planner/README.md) - `ClaudeAgent` task planning +- [examples/claude_agent_with_cancel](./examples/claude_agent_with_cancel/README.md) - `ClaudeAgent` cancellation + +Related docs: [langgraph_agent.md](./docs/mkdocs/en/langgraph_agent.md) / [claude_agent.md](./docs/mkdocs/en/claude_agent.md) / [human_in_the_loop.md](./docs/mkdocs/en/human_in_the_loop.md) / [cancel.md](./docs/mkdocs/en/cancel.md) + +This group helps you: + +- Reuse existing LangGraph assets in the current runtime with `LangGraphAgent` +- Use `ClaudeAgent` for code generation, engineering automation, and streaming tools +- Cover production-ready patterns like human-in-the-loop and cancellation + +Start with this snippet (`ClaudeAgent`): + +```python +root_agent = ClaudeAgent( + name="claude_weather_agent", + model=_create_model(), + instruction=INSTRUCTION, + tools=[FunctionTool(get_weather)], + enable_session=True, +) +``` + +### 6. Tools and MCP + +Recommended first: + +- [examples/function_tools](./examples/function_tools/README.md) - `FunctionTool` +- [examples/file_tools](./examples/file_tools/README.md) - File tools +- [examples/tools](./examples/tools/README.md) - Basic tool combinations +- [examples/toolsets](./examples/toolsets/README.md) - ToolSet composition +- [examples/streaming_tools](./examples/streaming_tools/README.md) - Streaming tool calling +- [examples/mcp_tools](./examples/mcp_tools/README.md) - `MCPToolset` (`stdio` / `sse` / `streamable-http`) +- [examples/langchain_tools](./examples/langchain_tools/README.md) - LangChain tools integration +- [examples/agent_tools](./examples/agent_tools/README.md) - Agent as a Tool + +Related docs: [tool.md](./docs/mkdocs/en/tool.md) + +This group helps you: + +- Cover the full tool access path from function tools to MCP to composed toolsets +- Learn advanced modes such as streaming tools and Agent-as-Tool +- Reuse existing tool implementations in multi-agent scenarios + +Start with this snippet (`MCPToolset`): + +```python +class StdioMCPToolset(MCPToolset): + def __init__(self): + super().__init__() + self._connection_params = StdioConnectionParams( + server_params=McpStdioServerParameters(command="python3", args=["mcp_server.py"]), + timeout=5, + ) +``` + +### 7. Skills + +Recommended first: + +- [examples/skills](./examples/skills/README.md) - `SkillToolSet` basics +- [examples/skills_with_container](./examples/skills_with_container/README.md) - Skills in containers +- [examples/skills_with_dynamic_tools](./examples/skills_with_dynamic_tools/README.md) - Dynamic tool skills + +Related docs: [skill.md](./docs/mkdocs/en/skill.md) + +This group helps you: + +- Package reusable capabilities into Skills +- Support scenario-based dynamic tool composition +- Build reusable business skill modules + +Start with this snippet (`SkillToolSet`): + +```python +workspace_runtime = create_local_workspace_runtime() +repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime) +skill_tool_set = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs) +``` + +### 8. CodeExecutor + +Recommended first: + +- [examples/code_executors](./examples/code_executors/README.md) - `UnsafeLocalCodeExecutor` / `ContainerCodeExecutor` + +Related docs: [code_executor.md](./docs/mkdocs/en/code_executor.md) + +This group helps you: + +- Choose local or containerized executors by runtime constraints +- Let agents execute code and ground tasks within controlled boundaries +- Combine with Skills/Tools for planning-and-execution loops + +### 9. Session, Memory, and Knowledge + +Recommended first: + +- Session: [examples/session_service_with_in_memory](./examples/session_service_with_in_memory/README.md) / [examples/session_service_with_redis](./examples/session_service_with_redis/README.md) / [examples/session_service_with_sql](./examples/session_service_with_sql/README.md) / [examples/session_summarizer](./examples/session_summarizer/README.md) / [examples/session_state](./examples/session_state/README.md) +- Memory: [examples/memory_service_with_in_memory](./examples/memory_service_with_in_memory/README.md) / [examples/memory_service_with_redis](./examples/memory_service_with_redis/README.md) / [examples/memory_service_with_sql](./examples/memory_service_with_sql/README.md) / [examples/memory_service_with_mem0](./examples/memory_service_with_mem0/README.md) / [examples/memory_service_with_mempalace](./examples/memory_service_with_mempalace/README.md) +- Knowledge: [examples/knowledge_with_documentloader](./examples/knowledge_with_documentloader/README.md) / [examples/knowledge_with_vectorstore](./examples/knowledge_with_vectorstore/README.md) / [examples/knowledge_with_rag_agent](./examples/knowledge_with_rag_agent/README.md) / [examples/knowledge_with_searchtool_rag_agent](./examples/knowledge_with_searchtool_rag_agent/README.md) / [examples/knowledge_with_prompt_template](./examples/knowledge_with_prompt_template/README.md) / [examples/knowledge_with_custom_components](./examples/knowledge_with_custom_components/README.md) + +Related docs: +- Session: [session.md](./docs/mkdocs/en/session.md) / [session_redis.md](./docs/mkdocs/en/session_redis.md) / [session_sql.md](./docs/mkdocs/en/session_sql.md) / [session_summary.md](./docs/mkdocs/en/session_summary.md) +- Memory: [memory.md](./docs/mkdocs/en/memory.md) +- Knowledge: [knowledge.md](./docs/mkdocs/en/knowledge.md) / [knowledge_document_loader.md](./docs/mkdocs/en/knowledge_document_loader.md) / [knowledge_retrievers.md](./docs/mkdocs/en/knowledge_retrievers.md) / [knowledge_vectorstore.md](./docs/mkdocs/en/knowledge_vectorstore.md) / [knowledge_prompt_template.md](./docs/mkdocs/en/knowledge_prompt_template.md) / [knowledge_custom_components.md](./docs/mkdocs/en/knowledge_custom_components.md) + +This group helps you: + +- Session: manage per-session messages, summaries, and state +- Memory: manage cross-session long-term memory (including Mem0, Mempalace) +- Knowledge: cover document loading, retrieval, RAG, and prompt templates + +### 10. Serving and Protocols + +Recommended first: + +- [examples/fastapi_server](./examples/fastapi_server/README.md) - HTTP service (sync + SSE) +- [examples/a2a](./examples/a2a/README.md) / [examples/a2a_with_cancel](./examples/a2a_with_cancel/README.md) - A2A service and cancellation +- [examples/agui](./examples/agui/README.md) / [examples/agui_with_cancel](./examples/agui_with_cancel/README.md) - AG-UI service and cancellation + +Related docs: [a2a.md](./docs/mkdocs/en/a2a.md) / [agui.md](./docs/mkdocs/en/agui.md) / [cancel.md](./docs/mkdocs/en/cancel.md) + +This group helps you: + +- Expose services through HTTP / A2A / AG-UI +- Integrate streaming responses and cancellation into real applications +- Use minimal templates for production service rollout + +### 11. Filters and Execution Control + +Recommended first: + +- [examples/filter_with_model](./examples/filter_with_model/README.md) - Model-level filters +- [examples/filter_with_tool](./examples/filter_with_tool/README.md) - Tool-level filters +- [examples/filter_with_agent](./examples/filter_with_agent/README.md) - Agent-level filters +- [examples/llmagent_with_branch_filtering](./examples/llmagent_with_branch_filtering/README.md) - Branch filtering +- [examples/llmagent_with_timeline_filtering](./examples/llmagent_with_timeline_filtering/README.md) - Timeline filtering +- [examples/llmagent_with_cancel](./examples/llmagent_with_cancel/README.md) - Execution cancellation + +Related docs: [filter.md](./docs/mkdocs/en/filter.md) / [cancel.md](./docs/mkdocs/en/cancel.md) + +This group helps you: + +- Apply control policies at model, tool, and agent layers +- Cover branch filtering, timeline filtering, and cancellation +- Build strong governance and risk-control constraints + +### 12. Advanced LlmAgent Capabilities + +Recommended first: + +- [examples/llmagent_with_tool_prompt](./examples/llmagent_with_tool_prompt/README.md) - Tool-call prompt enhancement +- [examples/llmagent_with_thinking](./examples/llmagent_with_thinking/README.md) - Thinking mode +- [examples/llmagent_with_user_history](./examples/llmagent_with_user_history/README.md) - User history management +- [examples/llmagent_with_max_history_messages](./examples/llmagent_with_max_history_messages/README.md) - History window limits +- [examples/llmagent_with_model_create_fn](./examples/llmagent_with_model_create_fn/README.md) - Dynamic model factory +- [examples/llmagent_with_custom_agent](./examples/llmagent_with_custom_agent/README.md) - Custom agent extension + +Related docs: [llm_agent.md](./docs/mkdocs/en/llm_agent.md) / [model.md](./docs/mkdocs/en/model.md) / [custom_agent.md](./docs/mkdocs/en/custom_agent.md) + +This group helps you: + +- Focus on `LlmAgent` extension points for context, prompting, and model routing +- Adapt a general-purpose agent to domain-specific business policies +- Build reusable behavior templates for repeated scenarios + +### 13. LlmAgent Tool Calling and Interaction + +Recommended first: + +- [examples/llmagent_with_streaming_tool_simple](./examples/llmagent_with_streaming_tool_simple/README.md) - Simple streaming tool calls +- [examples/llmagent_with_streaming_tool_complex](./examples/llmagent_with_streaming_tool_complex/README.md) - Complex streaming tool calls +- [examples/llmagent_with_parallal_tools](./examples/llmagent_with_parallal_tools/README.md) - Parallel tool calling (directory name intentionally uses `parallal`) +- [examples/llmagent_with_human_in_the_loop](./examples/llmagent_with_human_in_the_loop/README.md) - Human-in-the-loop decisions + +Related docs: [llm_agent.md](./docs/mkdocs/en/llm_agent.md) / [tool.md](./docs/mkdocs/en/tool.md) / [human_in_the_loop.md](./docs/mkdocs/en/human_in_the_loop.md) + +This group helps you: + +- Cover both simple and complex streaming tool interaction patterns +- Orchestrate parallel tool calls with human confirmation nodes +- Combine with filters and cancellation for more reliable execution chains + +> For more examples, see each subdirectory README.md under [examples](./examples). + +## Architecture Overview + +![tRPC-Agent-Python Architecture](./docs/mkdocs/assets/imgs/architecture.png) + +The framework is organized in an event-driven architecture where each layer can evolve independently: + +- **Agent layer**: LlmAgent / ChainAgent / ParallelAgent / CycleAgent / TransferAgent +- **Agent ecosystem extension layer**: LangGraphAgent / ClaudeAgent / TeamAgent +- **Graph capability layer**: GraphAgent / trpc_agent_sdk.dsl.graph (DSL-based orchestration) +- **Runner layer**: Unified execution entry, coordinating Session / Memory / Artifact services +- **Tool layer**: FunctionTool / file tools / MCPToolset / Skill tools +- **Model layer**: OpenAIModel / AnthropicModel / LiteLLMModel +- **Memory layer**: SessionService / MemoryService / SessionSummarizer / Mem0MemoryService / MempalaceMemoryService +- **Knowledge layer**: Production-grade LangChain-based knowledge and RAG capability +- **Execution and skill layer**: CodeExecutor (local / container) / Skills +- **Service layer**: FastAPI / A2A / AG-UI +- **Observability layer**: OpenTelemetry tracing/metrics, integrable with platforms like Langfuse +- **Ecosystem adapter layer**: claude-agent-sdk / mcp / LangChain / LiteLLM / Mem0 / Mempalace plugged into the main chain through model/tool/memory adapters + +Key packages: + +| Package | Responsibility | +| --- | --- | +| trpc_agent_sdk.agents | Agent abstractions, multi-agent orchestration, ecosystem extensions (LangGraphAgent / ClaudeAgent / TeamAgent) | +| trpc_agent_sdk.runners | Unified execution and event output | +| trpc_agent_sdk.models | Model adapter layer | +| trpc_agent_sdk.tools | Tooling system and MCP support | +| trpc_agent_sdk.sessions | Session management and summarization | +| trpc_agent_sdk.memory | Long-term memory services | +| trpc_agent_sdk.dsl.graph | DSL graph orchestration engine | +| trpc_agent_sdk.teams | Team collaboration mode | +| trpc_agent_sdk.code_executors | Code execution and workspace runtime | +| trpc_agent_sdk.skills | Skill repository and Skill tools | +| trpc_agent_sdk.server | FastAPI / A2A / AG-UI serving capabilities | + +## Contributing + +We love contributions! Join our growing developer community and help build the future of AI Agents. + +### **Ways to Contribute** + +- **Report bugs** or suggest new features through [Issues](https://github.com/trpc-group/trpc-agent-python/issues) +- **Improve documentation** to help others onboard faster +- **Submit PRs** for bug fixes, new features, or examples +- **Share your use cases** to inspire other builders + +### **Quick Contribution Setup** + +```bash +# Fork and clone the repository +git clone https://github.com/YOUR_USERNAME/trpc-agent-python.git +cd trpc-agent-python + +# Install development dependencies and run tests +pip install -e ".[dev]" +pytest + +# Make your changes and open a PR! +``` + +**Please read** [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines and coding standards. +**Please follow** [CODE-OF-CONDUCT.md](CODE-OF-CONDUCT.md) to keep our community friendly, respectful, and inclusive. + +## Acknowledgements + +### **Enterprise Validation** + +We sincerely thank Tencent Licaitong, Tencent Ads, and other business teams for continuous validation and feedback in real production scenarios, which helps us keep improving the framework. + +### **Open-source Inspiration** + +We are also inspired by outstanding open-source frameworks including **ADK**, **Agno**, **CrewAI**, and **AutoGen**. We keep moving forward on the shoulders of giants. + +--- + +If this project helps you, a GitHub Star is always appreciated — it's the most direct encouragement and helps more developers discover this project. diff --git a/README.zh_CN.md b/README.zh_CN.md new file mode 100644 index 000000000..b5619c2ae --- /dev/null +++ b/README.zh_CN.md @@ -0,0 +1,612 @@ +[English](README.md) | 中文 + +# tRPC-Agent-Python + +[![PyPI Version](https://img.shields.io/pypi/v/trpc-agent-py.svg)](https://pypi.org/project/trpc-agent-py/) +[![Python Versions](https://img.shields.io/pypi/pyversions/trpc-agent-py.svg)](https://pypi.org/project/trpc-agent-py/) +[![LICENSE](https://img.shields.io/badge/license-Apache--2.0-green.svg)](https://github.com/trpc-group/trpc-agent-python/blob/main/LICENSE) +[![Releases](https://img.shields.io/github/release/trpc-group/trpc-agent-python.svg?style=flat-square)](https://github.com/trpc-group/trpc-agent-python/releases) +[![Coverage](https://codecov.io/gh/trpc-group/trpc-agent-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/trpc-group/trpc-agent-python/tree/main) +[![Documentation](https://img.shields.io/badge/Docs-Website-blue.svg)](https://trpc-group.github.io/trpc-agent-python/) + +**深度融合 Python AI 生态的生产级 Agent 开发框架**。 +tRPC-Agent-Python 提供从 Agent 构建、编排、工具接入、会话记忆,到服务化部署与可观测的完整能力,帮助你快速落地可运行、可扩展、可维护的智能体应用。 + +## 为什么选择 tRPC-Agent-Python? + +- **多范式 Agent 编排**:预设编排支持 ChainAgent / ParallelAgent / CycleAgent / TransferAgent,同时支持 GraphAgent 图编排 +- **图编排能力(GraphAgent)**:通过 DSL 统一编排 Agent / Tool / MCP / Knowledge / CodeExecutor +- **高效接入 Python AI 生态扩展**:Agent 生态扩展(claude-agent-sdk / LangGraph 等)/ 工具生态扩展(mcp 等)/ 知识库生态扩展(LangChain 等)/ 模型生态扩展(LiteLLM 等)/ 记忆生态扩展(Mem0、Mempalace等) +- **Agent 生态扩展**:支持 LangGraphAgent / ClaudeAgent / TeamAgent(Agno-Like) +- **Tool 生态扩展**:FunctionTool / 文件工具 / MCPToolset / LangChain Tool / Agent-as-Tool +- **完善的记忆能力(Session / Memory)**:Session 负责单会话内的消息与状态管理,Memory 负责跨会话长期记忆与个性化信息沉淀。持久化支持 InMemory / Redis / SQL,Memory 还支持 Mem0、Mempalace +- **生产级知识库能力**:知识库能力基于 LangChain 组件构建,支持 RAG 场景 +- **CodeExecutor 扩展能力**:支持本地 / 容器执行器,用于支持 Agent 的代码执行与任务落地能力 +- **Skills 扩展能力**:支持 SKILL.md 技能体系,用于支持 Agent 的技能复用与动态工具化能力 +- **对接多种 LLM Provider**:OpenAI-like / Anthropic / LiteLLM 路由 +- **服务化与可观测**:支持通过 FastAPI 提供 HTTP / A2A / AG-UI 的服务,内置 OpenTelemetry 追踪 +- **trpc-claw(OpenClaw-like Agent)**:基于 [nanobot](https://github.com/HKUDS/nanobot) 构建,tRPC-Agent 提供 trpc-claw 能力,方便快速开发一个支持 Telegram / 企业微信等通道的 OpenClaw-like 个人 AI Agent + +## 使用场景 + +- 智能客服与知识问答(RAG + 会话记忆) +- 代码生成与工程自动化(ClaudeAgent) +- 代码执行与自动化任务落地(CodeExecutor) +- 使用 Agent Skills +- 多角色协作任务(TeamAgent / Multi-Agent) +- 跨协议 Agent 服务接入(A2A / AG-UI) +- MCP 工具协议接入与工具生态扩展 +- 面向网关场景的统一接入与协议转换 +- 基于 GraphAgent 的组件化工作流编排 +- 复用已有 LangGraph 工作流并接入当前体系 +- 快速打造 OpenClaw-like 个人 AI Agent(trpc-claw) + +## 目录 + +- [tRPC-Agent-Python](#trpc-agent-python) + - [为什么选择 tRPC-Agent-Python?](#为什么选择-trpc-agent-python) + - [使用场景](#使用场景) + - [目录](#目录) + - [快速开始](#快速开始) + - [trpc-claw 用法](#trpc-claw-用法) + - [文档](#文档) + - [示例](#示例) + - [1. 入门与基础 Agent](#1-入门与基础-agent) + - [2. 多 Agent 预设编排](#2-多-agent-预设编排) + - [3. Team 协作](#3-team-协作) + - [4. 图编排能力(GraphAgent / DSL)](#4-图编排能力graphagent--dsl) + - [5. Agent 生态扩展(LangGraphAgent / ClaudeAgent)](#5-agent-生态扩展langgraphagent--claudeagent) + - [6. Tool 与 MCP](#6-tool-与-mcp) + - [7. Skills](#7-skills) + - [8. CodeExecutor](#8-codeexecutor) + - [9. Session / Memory / Knowledge](#9-session--memory--knowledge) + - [10. 服务化与协议](#10-服务化与协议) + - [11. 过滤器与执行控制](#11-过滤器与执行控制) + - [12. LlmAgent 进阶能力](#12-llmagent-进阶能力) + - [13. LlmAgent 工具调用与交互](#13-llmagent-工具调用与交互) + - [架构概览](#架构概览) + - [贡献](#贡献) + - [致谢](#致谢) + +## 快速开始 + +### 前置条件 + +- Python 3.10+(推荐 Python 3.12) +- 可用的模型服务 API Key(OpenAI-like / Anthropic,或通过 LiteLLM 路由) + +### 安装 + +```bash +pip install trpc-agent-py +``` + +按需安装扩展能力: + +```bash +pip install trpc-agent-py[a2a,ag-ui,knowledge,agent-claude,mem0, Mempalace, langfuse] +``` + + +### 开发天气查询Agent + +```python +import asyncio +import os +import uuid + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import Content, Part + + +async def get_weather_report(city: str) -> dict: + return {"city": city, "temperature": "25°C", "condition": "Sunny", "humidity": "60%"} + + +async def main(): + model = OpenAIModel( + model_name=os.environ["TRPC_AGENT_MODEL_NAME"], + api_key=os.environ["TRPC_AGENT_API_KEY"], + base_url=os.environ.get("TRPC_AGENT_BASE_URL", ""), + ) + + agent = LlmAgent( + name="assistant", + description="A helpful assistant", + model=model, + instruction="You are a helpful assistant.", + tools=[FunctionTool(get_weather_report)], + ) + + session_service = InMemorySessionService() + runner = Runner(app_name="demo_app", agent=agent, session_service=session_service) + + user_id = "demo_user" + session_id = str(uuid.uuid4()) + user_content = Content(parts=[Part.from_text(text="What's the weather in Beijing?")]) + + async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=user_content): + if not event.content or not event.content.parts: + continue + for part in event.content.parts: + if part.text and event.partial: + print(part.text, end="", flush=True) + elif part.function_call: + print(f"\n🔧 [{part.function_call.name}({part.function_call.args})]", flush=True) + elif part.function_response: + print(f"📊 [{part.function_response.response}]", flush=True) + + print() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 运行Agent + +```bash +export TRPC_AGENT_API_KEY=xxx +export TRPC_AGENT_BASE_URL=xxxx +export TRPC_AGENT_MODEL_NAME=xxxx +python quickstart.py +``` + +## trpc-claw 用法 + +tRPC-Agent 基于 [nanobot](https://github.com/HKUDS/nanobot) 提供了 trpc-claw 能力(`trpc_agent_cmd openclaw`),方便你快速打造一个 OpenClaw-like 的个人 AI Agent:配置好后一条命令启动,可 7×24 小时在线,通过 Telegram、企业微信等常用 IM 与 Agent 交互,或直接在本地 CLI / UI 使用。 + +详细配置与高级功能参见:[openclaw.md](./docs/mkdocs/zh/openclaw.md) + +### 快速上手 + +**1. 生成配置文件** + +```bash +mkdir -p ~/.trpc_claw +trpc_agent_cmd openclaw conf_temp > ~/.trpc_claw/config.yaml +``` + +**2. 配置环境变量** + +```bash +export TRPC_AGENT_API_KEY=your_api_key +export TRPC_AGENT_BASE_URL=your_base_url +export TRPC_AGENT_MODEL_NAME=your_model +``` + +**3. 本地运行** + +```bash +# 强制本地 CLI +trpc_agent_cmd openclaw chat -c ~/.trpc_claw/config.yaml + +# 本地 UI +trpc_agent_cmd openclaw ui -c ~/.trpc_claw/config.yaml +``` + +**4. 接入企业微信 / Telegram** + +在 `config.yaml` 中启用对应通道,然后 `run` 启动: + +```yaml +channels: + wecom: + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + # 或 Telegram: + # telegram: + # enabled: true + # token: ${TELEGRAM_BOT_TOKEN} +``` + +```bash +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config.yaml +``` + +`run` 模式下若无可用通道会自动回退到本地 CLI,方便调试。 + +## 更多文档 + +- 文档目录:[`docs/mkdocs/zh`](./docs/mkdocs/zh) + +## 示例 + +`examples` 目录里的示例都可以直接运行。下面按能力分组整理了“建议先看”的示例,并给了简短阅读提示,方便你按场景快速定位。 + +### 1. 入门与基础 Agent + +建议先看: + +- [examples/quickstart](./examples/quickstart/README.md) - 最小可运行示例 +- [examples/llmagent](./examples/llmagent/README.md) - 基础 LlmAgent +- [examples/litellm](./examples/litellm/README.md) - LiteLLMModel 统一模型后端 +- [examples/llmagent_with_custom_prompt](./examples/llmagent_with_custom_prompt/README.md) - 自定义提示词 +- [examples/llmagent_with_schema](./examples/llmagent_with_schema/README.md) - 结构化输出 + +相关文档:[llm_agent.md](./docs/mkdocs/zh/llm_agent.md) / [model.md](./docs/mkdocs/zh/model.md) + +这组示例可以帮你: + +- 快速跑通从用户输入到工具调用再到模型输出的完整链路 +- 理解流式输出中 function_call / function_response 事件的处理方式 +- 掌握自定义 Prompt 和结构化输出的基础写法 + +可以先看这段代码(Runner + 流式事件): + +```python +runner = Runner(app_name=app_name, agent=root_agent, session_service=session_service) +async for event in runner.run_async(user_id=user_id, session_id=current_session_id, new_message=user_content): + if event.partial and event.content: + ... +``` + +### 2. 多 Agent 预设编排 + +建议先看: + +- [examples/multi_agent_chain](./examples/multi_agent_chain/README.md) - ChainAgent +- [examples/multi_agent_parallel](./examples/multi_agent_parallel/README.md) - ParallelAgent +- [examples/multi_agent_cycle](./examples/multi_agent_cycle/README.md) - CycleAgent +- [examples/transfer_agent](./examples/transfer_agent/README.md) - TransferAgent 交接执行 +- [examples/multi_agent_subagent](./examples/multi_agent_subagent/README.md) - Sub-agent 委派 +- [examples/multi_agent_compose](./examples/multi_agent_compose/README.md) - 组合式编排 +- [examples/multi_agent_start_from_last](./examples/multi_agent_start_from_last/README.md) - 从上轮 Agent 继续执行 + +相关文档:[multi_agents.md](./docs/mkdocs/zh/multi_agents.md) + +这组示例可以帮你: + +- 看懂 Chain / Parallel / Cycle / Transfer 的职责差异 +- 按任务形态选择串行、并行、循环或交接的编排方式 +- 学会在已有结果基础上续跑与组合子流程 + +可以先看这段代码(ChainAgent): + +```python +pipeline = ChainAgent( + name="document_processor", + sub_agents=[extractor_agent, translator_agent], +) +``` + +### 3. Team 协作 + +建议先看: + +- [examples/team](./examples/team/README.md) - Team 协调模式 +- [examples/team_parallel_execution](./examples/team_parallel_execution/README.md) - Team 并行执行 +- [examples/team_with_skill](./examples/team_with_skill/README.md) - Team + Skills +- [examples/team_human_in_the_loop](./examples/team_human_in_the_loop/README.md) - Team 人机协同 +- [examples/team_as_sub_agent](./examples/team_as_sub_agent/README.md) - Team 作为子 Agent +- [examples/team_member_message_filter](./examples/team_member_message_filter/README.md) - Team 成员消息过滤 +- [examples/team_member_agent_claude](./examples/team_member_agent_claude/README.md) - Team 成员接入 ClaudeAgent +- [examples/team_member_agent_langgraph](./examples/team_member_agent_langgraph/README.md) - Team 成员接入 LangGraphAgent +- [examples/team_member_agent_team](./examples/team_member_agent_team/README.md) - Team 成员嵌套 Team +- [examples/team_with_cancel](./examples/team_with_cancel/README.md) - Team 任务取消 + +相关文档:[team.md](./docs/mkdocs/zh/team.md) / [human_in_the_loop.md](./docs/mkdocs/zh/human_in_the_loop.md) / [cancel.md](./docs/mkdocs/zh/cancel.md) + +这组示例可以帮你: + +- 理解 Team 的 Leader / Member 协作模式 +- 学会在 Team 中接入 Skills、子 Team、外部 Agent +- 覆盖消息过滤、人机协同、取消控制等常见工程需求 + +可以先看这段代码(TeamAgent): + +```python +content_team = TeamAgent( + name="content_team", + model=model, + members=[researcher, writer], + instruction=LEADER_INSTRUCTION, + share_member_interactions=True, +) +``` + +### 4. 图编排能力(GraphAgent / DSL) + +建议先看: + +- [examples/graph](./examples/graph/README.md) - GraphAgent:编排 function / llm / agent / code / mcp / knowledge 节点 +- [examples/graph_multi_turns](./examples/graph_multi_turns/README.md) - 多轮图执行 +- [examples/graph_with_interrupt](./examples/graph_with_interrupt/README.md) - 图执行中断 +- [examples/dsl](./examples/dsl/README.md) - DSL 能力示例 +- [examples/dsl/classifier_mcp](./examples/dsl/classifier_mcp/README.md) - DSL + MCP 分类路由 + +相关文档:[graph.md](./docs/mkdocs/zh/graph.md) / [dsl.md](./docs/mkdocs/zh/dsl.md) + +这组示例可以帮你: + +- 适合需要显式流程控制的任务(分支、合并、中断、续跑) +- 支持在同一图中混合 Agent / Tool / MCP / CodeExecutor / Knowledge +- 用 DSL 更快搭建可读、可维护的工作流 + +可以先看这段代码(条件路由): + +```python +graph.add_conditional_edges( + "decide", + create_route_choice(set(path_map.keys())), + path_map, +) +``` + +### 5. Agent 生态扩展(LangGraphAgent / ClaudeAgent) + +建议先看: + +- [examples/langgraph_agent](./examples/langgraph_agent/README.md) - 对接用户使用 LangGraph 开发并 compile 的 Agent 工作流 +- [examples/langgraph_agent_with_cancel](./examples/langgraph_agent_with_cancel/README.md) - LangGraphAgent 任务取消 +- [examples/langgraphagent_with_human_in_the_loop](./examples/langgraphagent_with_human_in_the_loop/README.md) - LangGraphAgent 人机协同 +- [examples/claude_agent](./examples/claude_agent/README.md) - ClaudeAgent 基础用法 +- [examples/claude_agent_with_streaming_tool](./examples/claude_agent_with_streaming_tool/README.md) - ClaudeAgent 流式工具调用 +- [examples/claude_agent_with_skills](./examples/claude_agent_with_skills/README.md) - ClaudeAgent + Skills +- [examples/claude_agent_with_code_writer](./examples/claude_agent_with_code_writer/README.md) - ClaudeAgent 代码生成 +- [examples/claude_agent_with_travel_planner](./examples/claude_agent_with_travel_planner/README.md) - ClaudeAgent 任务编排 +- [examples/claude_agent_with_cancel](./examples/claude_agent_with_cancel/README.md) - ClaudeAgent 任务取消 + +相关文档:[langgraph_agent.md](./docs/mkdocs/zh/langgraph_agent.md) / [claude_agent.md](./docs/mkdocs/zh/claude_agent.md) / [human_in_the_loop.md](./docs/mkdocs/zh/human_in_the_loop.md) / [cancel.md](./docs/mkdocs/zh/cancel.md) + +这组示例可以帮你: + +- 用 LangGraphAgent 复用已有 LangGraph 实现并接入当前运行时 +- 用 ClaudeAgent 处理代码生成、工程自动化、流式工具调用 +- 覆盖人机协同与取消控制,便于落地真实业务流程 + +可以先看这段代码(ClaudeAgent): + +```python +root_agent = ClaudeAgent( + name="claude_weather_agent", + model=_create_model(), + instruction=INSTRUCTION, + tools=[FunctionTool(get_weather)], + enable_session=True, +) +``` + +### 6. Tool 与 MCP + +建议先看: + +- [examples/function_tools](./examples/function_tools/README.md) - FunctionTool +- [examples/file_tools](./examples/file_tools/README.md) - 文件工具 +- [examples/tools](./examples/tools/README.md) - Tool 基础组合 +- [examples/toolsets](./examples/toolsets/README.md) - ToolSet 组合 +- [examples/streaming_tools](./examples/streaming_tools/README.md) - 流式工具调用 +- [examples/mcp_tools](./examples/mcp_tools/README.md) - MCPToolset(stdio / sse / streamable-http) +- [examples/langchain_tools](./examples/langchain_tools/README.md) - LangChain 工具接入 +- [examples/agent_tools](./examples/agent_tools/README.md) - Agent 作为 Tool + +相关文档:[tool.md](./docs/mkdocs/zh/tool.md) + +这组示例可以帮你: + +- 从函数工具到协议工具(MCP)再到组合工具,接入路径完整 +- 覆盖流式工具调用与 Agent-as-Tool 等高级模式 +- 便于把现有工具实现复用到多 Agent 场景 + +可以先看这段代码(MCPToolset): + +```python +class StdioMCPToolset(MCPToolset): + def __init__(self): + super().__init__() + self._connection_params = StdioConnectionParams( + server_params=McpStdioServerParameters(command="python3", args=["mcp_server.py"]), + timeout=5, + ) +``` + +### 7. Skills + +建议先看: + +- [examples/skills](./examples/skills/README.md) - SkillToolSet 基础 +- [examples/skills_with_container](./examples/skills_with_container/README.md) - 容器运行 Skill +- [examples/skills_with_dynamic_tools](./examples/skills_with_dynamic_tools/README.md) - 动态工具 Skill + +相关文档:[skill.md](./docs/mkdocs/zh/skill.md) + +这组示例可以帮你: + +- 把可复用能力沉淀为 Skills,减少重复开发 +- 支持按场景动态装配工具能力 +- 便于沉淀可复用的业务技能模块 + +可以先看这段代码(SkillToolSet): + +```python +workspace_runtime = create_local_workspace_runtime() +repository = create_default_skill_repository(skill_paths, workspace_runtime=workspace_runtime) +skill_tool_set = SkillToolSet(repository=repository, run_tool_kwargs=tool_kwargs) +``` + +### 8. CodeExecutor + +建议先看: + +- [examples/code_executors](./examples/code_executors/README.md) - UnsafeLocalCodeExecutor / ContainerCodeExecutor + +相关文档:[code_executor.md](./docs/mkdocs/zh/code_executor.md) + +这组示例可以帮你: + +- 按执行环境选择本地或容器执行器 +- 让 Agent 在可控边界内完成代码执行与任务落地 +- 与 Skills/Tool 组合形成“规划 + 执行”闭环 + +### 9. Session / Memory / Knowledge + +建议先看: + +- Session:[examples/session_service_with_in_memory](./examples/session_service_with_in_memory/README.md) / [examples/session_service_with_redis](./examples/session_service_with_redis/README.md) / [examples/session_service_with_sql](./examples/session_service_with_sql/README.md) / [examples/session_summarizer](./examples/session_summarizer/README.md) / [examples/session_state](./examples/session_state/README.md) +- Memory: [examples/memory_service_with_in_memory](./examples/memory_service_with_in_memory/README.md) / [examples/memory_service_with_redis](./examples/memory_service_with_redis/README.md) / [examples/memory_service_with_sql](./examples/memory_service_with_sql/README.md) / [examples/memory_service_with_mem0](./examples/memory_service_with_mem0/README.md) / [examples/memory_service_with_mempalace](./examples/memory_service_with_mempalace/README.md) +- Knowledge:[examples/knowledge_with_documentloader](./examples/knowledge_with_documentloader/README.md) / [examples/knowledge_with_vectorstore](./examples/knowledge_with_vectorstore/README.md) / [examples/knowledge_with_rag_agent](./examples/knowledge_with_rag_agent/README.md) / [examples/knowledge_with_searchtool_rag_agent](./examples/knowledge_with_searchtool_rag_agent/README.md) / [examples/knowledge_with_prompt_template](./examples/knowledge_with_prompt_template/README.md) / [examples/knowledge_with_custom_components](./examples/knowledge_with_custom_components/README.md) + +相关文档: +- Session:[session.md](./docs/mkdocs/zh/session.md) / [session_redis.md](./docs/mkdocs/zh/session_redis.md) / [session_sql.md](./docs/mkdocs/zh/session_sql.md) / [session_summary.md](./docs/mkdocs/zh/session_summary.md) +- Memory:[memory.md](./docs/mkdocs/zh/memory.md) +- Knowledge:[knowledge.md](./docs/mkdocs/zh/knowledge.md) / [knowledge_document_loader.md](./docs/mkdocs/zh/knowledge_document_loader.md) / [knowledge_retrievers.md](./docs/mkdocs/zh/knowledge_retrievers.md) / [knowledge_vectorstore.md](./docs/mkdocs/zh/knowledge_vectorstore.md) / [knowledge_prompt_template.md](./docs/mkdocs/zh/knowledge_prompt_template.md) / [knowledge_custom_components.md](./docs/mkdocs/zh/knowledge_custom_components.md) + +这组示例可以帮你: + +- Session:管理单会话的消息、摘要与状态 +- Memory:管理跨会话长期记忆(含 Mem0, Mempalace) +- Knowledge:覆盖文档加载、检索、RAG、提示模板等链路 + +### 10. 服务化与协议 + +建议先看: + +- [examples/fastapi_server](./examples/fastapi_server/README.md) - HTTP 服务(同步 + SSE) +- [examples/a2a](./examples/a2a/README.md) / [examples/a2a_with_cancel](./examples/a2a_with_cancel/README.md) - A2A 服务与取消 +- [examples/agui](./examples/agui/README.md) / [examples/agui_with_cancel](./examples/agui_with_cancel/README.md) - AG-UI 服务与取消 + +相关文档:[a2a.md](./docs/mkdocs/zh/a2a.md) / [agui.md](./docs/mkdocs/zh/agui.md) / [cancel.md](./docs/mkdocs/zh/cancel.md) + +这组示例可以帮你: + +- 演示 HTTP / A2A / AG-UI 三种服务暴露方式 +- 覆盖流式返回与取消机制,便于接入网关和前端 +- 可直接作为服务化落地的最小模板 + +### 11. 过滤器与执行控制 + +建议先看: + +- [examples/filter_with_model](./examples/filter_with_model/README.md) - Model 级过滤 +- [examples/filter_with_tool](./examples/filter_with_tool/README.md) - Tool 级过滤 +- [examples/filter_with_agent](./examples/filter_with_agent/README.md) - Agent 级过滤 +- [examples/llmagent_with_branch_filtering](./examples/llmagent_with_branch_filtering/README.md) - 分支过滤 +- [examples/llmagent_with_timeline_filtering](./examples/llmagent_with_timeline_filtering/README.md) - 时间线过滤 +- [examples/llmagent_with_cancel](./examples/llmagent_with_cancel/README.md) - 执行取消 + +相关文档:[filter.md](./docs/mkdocs/zh/filter.md) / [cancel.md](./docs/mkdocs/zh/cancel.md) + +这组示例可以帮你: + +- 展示 Model / Tool / Agent 三层过滤策略 +- 覆盖分支过滤、时间线过滤、执行取消等控制能力 +- 适合治理、审计、风控等强约束场景 + +### 12. LlmAgent 进阶能力 + +建议先看: + +- [examples/llmagent_with_tool_prompt](./examples/llmagent_with_tool_prompt/README.md) - Tool 调用提示词增强 +- [examples/llmagent_with_thinking](./examples/llmagent_with_thinking/README.md) - 思考模式 +- [examples/llmagent_with_user_history](./examples/llmagent_with_user_history/README.md) - 用户历史消息管理 +- [examples/llmagent_with_max_history_messages](./examples/llmagent_with_max_history_messages/README.md) - 历史消息窗口限制 +- [examples/llmagent_with_model_create_fn](./examples/llmagent_with_model_create_fn/README.md) - 动态模型创建函数 +- [examples/llmagent_with_custom_agent](./examples/llmagent_with_custom_agent/README.md) - 自定义 Agent 扩展 + +相关文档:[llm_agent.md](./docs/mkdocs/zh/llm_agent.md) / [model.md](./docs/mkdocs/zh/model.md) / [custom_agent.md](./docs/mkdocs/zh/custom_agent.md) + +这组示例可以帮你: + +- 聚焦 LlmAgent 在上下文、提示词、模型路由上的可扩展点 +- 适合把通用 Agent 调整为贴近业务策略的 Agent +- 便于沉淀可复用的行为模板 + +### 13. LlmAgent 工具调用与交互 + +建议先看: + +- [examples/llmagent_with_streaming_tool_simple](./examples/llmagent_with_streaming_tool_simple/README.md) - 简单流式工具调用 +- [examples/llmagent_with_streaming_tool_complex](./examples/llmagent_with_streaming_tool_complex/README.md) - 复杂流式工具调用 +- [examples/llmagent_with_parallal_tools](./examples/llmagent_with_parallal_tools/README.md) - 并行工具调用(目录名沿用 `parallal`) +- [examples/llmagent_with_human_in_the_loop](./examples/llmagent_with_human_in_the_loop/README.md) - 人机协同决策 + +相关文档:[llm_agent.md](./docs/mkdocs/zh/llm_agent.md) / [tool.md](./docs/mkdocs/zh/tool.md) / [human_in_the_loop.md](./docs/mkdocs/zh/human_in_the_loop.md) + +这组示例可以帮你: + +- 覆盖简单 / 复杂两类流式工具调用模式 +- 演示并行工具调度与人机协同确认节点 +- 可与过滤器、取消机制组合,形成更稳的执行链路 + +> 更多示例请查看 [examples](./examples) 目录下各子目录 README.md。 + +## 架构概览 + +![tRPC-Agent-Python Architecture](./docs/mkdocs/assets/imgs/architecture.png) + +框架采用事件驱动方式组织组件,各层可独立扩展: + +- **Agent 层**:LlmAgent / ChainAgent / ParallelAgent / CycleAgent / TransferAgent +- **Agent 生态扩展层**:LangGraphAgent / ClaudeAgent / TeamAgent +- **图能力层**:GraphAgent / trpc_agent_sdk.dsl.graph(DSL 组件编排能力) +- **Runner 层**:统一执行入口,负责 Session/Memory/Artifact 等服务协同 +- **Tool 层**:FunctionTool / 文件工具 / MCPToolset / Skill 工具 +- **Model 层**:OpenAIModel / AnthropicModel / LiteLLMModel +- **Memory 层**:SessionService / MemoryService / SessionSummarizer / Mem0MemoryService / MempalaceMemoryService +- **Knowledge 层**:基于 LangChain 的生产级知识库能力(RAG) +- **执行与技能层**:CodeExecutor(本地/容器)/ Skills +- **服务层**:FastAPI / A2A / AG-UI +- **观测层**:OpenTelemetry tracing/metrics,可对接 Langfuse 等平台 +- **生态适配层**:claude-agent-sdk / mcp / LangChain / LiteLLM / Mem0 / MemoryService,通过模型/工具/记忆适配器接入主链路 + +关键包一览: + +| Package | 主要职责 | +| --- | --- | +| trpc_agent_sdk.agents | Agent 抽象 / 多 Agent 编排 / 生态扩展(LangGraphAgent / ClaudeAgent / TeamAgent) | +| trpc_agent_sdk.runners | 统一执行与事件输出 | +| trpc_agent_sdk.models | 模型适配层 | +| trpc_agent_sdk.tools | 工具体系与 MCP 支持 | +| trpc_agent_sdk.sessions | 会话管理与总结压缩 | +| trpc_agent_sdk.memory | 长期记忆服务 | +| trpc_agent_sdk.dsl.graph | DSL 图编排引擎 | +| trpc_agent_sdk.teams | Team 协作模式 | +| trpc_agent_sdk.code_executors | 代码执行与工作区运行时 | +| trpc_agent_sdk.skills | Skill 仓库 / Skill 工具 | +| trpc_agent_sdk.server | FastAPI / A2A / AG-UI 等服务能力 | + +## 贡献 + +我们热爱贡献!加入我们不断壮大的开发者社区,共同构建 AI Agent 的未来。 + +### **贡献方式** + +- **报告 bug** 或通过 [Issues](https://github.com/trpc-group/trpc-agent-python/issues) 建议新功能 +- **改进文档** - 帮助他人更快学习 +- **提交 PR** - bug 修复、新功能或示例 +- **分享您的用例** - 用您的 Agent 应用启发他人 + +### **快速贡献设置** + +```bash +# Fork 并克隆仓库 +git clone https://github.com/YOUR_USERNAME/trpc-agent-python.git +cd trpc-agent-python + +# 安装开发依赖并运行测试 +pip install -e ".[dev]" +pytest + +# 进行您的更改并提交 PR! +``` + +**请阅读** [CONTRIBUTING.md](CONTRIBUTING.md) 了解详细指南和编码标准。 +**请遵循** [CODE-OF-CONDUCT.md](CODE-OF-CONDUCT.md) 共同维护友好、尊重、包容的社区。 + +## 致谢 + +### **企业验证** + +感谢腾讯理财通、腾讯广告等业务团队在真实业务场景中的持续验证与反馈,帮助我们不断打磨框架能力。 + +### **开源灵感** + +也感谢 **ADK**、**Agno**、**CrewAI**、**AutoGen** 等优秀开源框架带来的启发,让我们能够站在巨人的肩膀上持续前进。 + +--- + +如果这个项目对你有帮助,欢迎在 GitHub 上点个 Star,这是对我们最直接的鼓励,也能帮助更多开发者发现这个项目。 diff --git a/build.sh b/build.sh new file mode 100755 index 000000000..f7dba5ffa --- /dev/null +++ b/build.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +pip install --upgrade pip + + +sh clean.sh + +pip3 install -r requirements.txt +pip3 install -r requirements-test.txt + +pip install -e .[dev] + +# 检查依赖解析 +pip install --dry-run . diff --git a/build_mac.sh b/build_mac.sh new file mode 100644 index 000000000..723f2e011 --- /dev/null +++ b/build_mac.sh @@ -0,0 +1,14 @@ +# 先注释 +set -e + +pip install --upgrade pip + +sh clean.sh + +pip install -r requirements.txt +pip install -r requirements-test.txt + +pip install -e '.[dev]' + +# 检查依赖解析 +pip install --dry-run . diff --git a/clean.sh b/clean.sh new file mode 100755 index 000000000..551a56ae1 --- /dev/null +++ b/clean.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +sudo rm -rf coverage.xml +sudo rm -rf *.log +sudo rm -rf htmlcov +sudo rm -rf .coverage +sudo rm -rf .__* +sudo rm -rf trpc-agent-py.egg-info +sudo rm -rf dist/ +sudo rm -rf build/ + + +sudo rm -rf test_tracemalloc* +sudo rm -rf test-ngtest-ut-trpc-agent-py* +sudo rm -rf cov.tmp +sudo rm -rf examples/*.lock +sudo rm -rf examples/*.log + +sudo rm -rf examples/.__py_trpc_frame.lock +sudo rm -rf examples/.__trpc.lock + + +find -type d | grep __pycache__ | xargs sudo rm -r + +find ./ -type f -name "*.log" -exec sudo rm {} \; + + +pip3 freeze > tmp_requirements.txt +pip3 uninstall -r tmp_requirements.txt -y + +sudo rm tmp_requirements.txt diff --git a/coverage.sh b/coverage.sh new file mode 100644 index 000000000..561ef96e2 --- /dev/null +++ b/coverage.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +sh +x build.sh + +pip3 install -r requirements-test.txt + +rm -rf coverage.xml +rm -rf test-ngtest-ut-trpc-agent-py.xml +rm -rf .coverage +rm -rf htmlcov* + + +test_dir=trpc_agent_py +pytest --cov-report html --cov-report term --cov-report xml --cov=$test_dir tests/ \ + --junitxml=test-ngtest-ut-$test_dir.xml > cov.tmp diff --git a/dist_pkg.sh b/dist_pkg.sh new file mode 100644 index 000000000..e89c08d55 --- /dev/null +++ b/dist_pkg.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +INIT_PY="trpc_agent_sdk/version.py" + +version=$(grep -oP "__version__ = '\K[^']+" "$INIT_PY") + +echo "version is ${version}" + +sh +x build.sh + +sudo rm -rf dist/ + +pip3 install urllib3 +pip install build +pip3 install twine +# dist source package +#python -m build --sdist +# dist wheel package +python -m build + + +twine upload --repository pypi dist/* + + diff --git a/docs/mkdocs/assets/imgs/agent_cancel.png b/docs/mkdocs/assets/imgs/agent_cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..648050d47b3e61882d2c7121697c984591bbaa90 GIT binary patch literal 68742 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 literal 0 HcmV?d00001 diff --git a/docs/mkdocs/assets/imgs/claude_agent_architecture.png b/docs/mkdocs/assets/imgs/claude_agent_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..5f88449b8f3963c2bce33e4d879d2b646b7f4f39 GIT binary patch literal 99756 zcmd?S1wd8V-aZV7q!LmpNOwp#(%ndhAPv%R=oV?DQBhhDkS-|!=|)OQ5h(%bZusv5 z=QvEzmr^TRGgeHU!|;3d+Dg z8o)O*lO8j>9w!a(QOMTT(pcTtK*k(+yEr>5=WSLFpqN@lQbkUdf<*-QY-w&`4E&HV zHngw-KO$mgZ({|Nh%>VRt%ARRLS=mueS7l@uK@Sd$=Ke(+{XHRFf81+Ic~F^55d7u z-_rQJnEAZKz{cLl*#5i-cpnP|s~8275bzH0Use&wEC3hQ`as)fvqK@qNg*Tx{tJu< zTuqFdLWr3{nEm{X=0@k$EV;~BBqg0S6-^vPKaDno#W*Ji_yMW`x z+t5l@E+WR3U>SwZBcvTbl>Yd$-)F=AyNdibuhZAykaM$EGt&aOh*)u0YA9Me+A#b^ z8;~LgkSkQGoz2Wa#>%$(hG0~j0ht2y(F|l|30yG)AN3t<&rvopcQFQ#1a$RGU-T^< zp~8J$U~CUEh6*9L&cC0{pXOA;7*J7=y(=)UW*2bffQqg2PeSm$4AKr8WX7P94!P8a zNTBKWH-mfpdr$)EKtTTYy$74XU&z950syoNZrWD=Cy;*)fW3{QwGnt~MFI06{2$-| zOt7Vhjit@G{b4m?H|8>810Hh#+1pqcU%11?V!+D5@eks~37P#1c(JiV${=__FClnw z{cw1({xQ73`tir`GGPaQ0gsv3Sc5L0#d`MVw{YY*`}5D?$jtF;9GRiI2*nYsO<=)> z0LjYq!yyUr-oID#{y36G`o>%)hQEoJA(yd%$v=k}8|$wTWBmnUP*sBf#?1V~0R{<$ z{{H|NGt>@VFqOXq42mBFFm|YQ{c(I7B+UH#0Q+lgC5I&t;GLOWz;68%{&3hif+N4P z8w?I-!7w-g_#PQ>q>+iw+?+cXvxVbhlG3GG*O>=F; z$!!2k$Uhd?vOt~ZMZ}=?7=qY$`wX?$Kc4z?{q3RBkAoOge=Z=#@(aXRpmm^#L2dkx zW3ZvbfU(i967XMZosFaYSwaY){F|8#^RM$5@OzaZd8Lg#$jrvn##-M}?59%UpJzHh z)seNa0kY1sbn}5R2;>SOCG;IZHh^AUNJfEF+1^zHk|~{CXo4?pgCpv*OHr@_ftC6E z3Jk_istwY&HwBofbH3^~^X0RA?cb+64$U;zJqlHpG%qpFj+p^d#YfEoo0n2s0M zcK`_~O8q^W2=$E@fMkV6MNmzIUPFO|YU&SURsVL5!NkOb%Ap3s*2ZHQxXBqwsWXi@4XKPP@|8ukknS&Rs{tojAh35}I_+2Z1APE0< z-ocm|VAVN+81h$~IyVO=tNuTzb(|1!y@(hzLV?&x7N}i>C>;wkt^n$<(GgBsZ%2)iFL%~t`|qaFS+)L5bX`o9QG*nU8jgDi>v zSP9=hn;QH_*r3|?<7gXH|NlK~Ku!f_qA$=QzqJPWbLVAkV-1iK!WUPMAw~L@=BD6n zFNOeP0^Zdk49?ojf$bPVkQ-L!Mn>Sh0>5ByAnJE<8^Ag1Z2;xF!8sv=5O@a=19)?f zy(vJE06T1cBFDei{CiXxs;7uW`W-RfkXnrv;8lK1bDFnN&rH-?euH; zaIn<}3&GiH%6H=DOiV6_#>GlMjC6cw<$oaY_+4z!k6|$`iU(x*`GR210Tb{K*}rKmkQr)EFZx{QQU%24vO~F42#(*Wogc^N zvi^_e>A$wQ-{UU`^4}fq-(`cL3UE;Xexb zxPKBKu*WraF$ZY?Rlyzs{8Z2a4txPtoU>=X@1VQQ%=CTR-1(CnTwLHc zfouHq@Y&VRo>nxr2iB<1$l|kCo-@trkYifRXPfcPqqYCI;SRz{T%duJ&ySA&oqO

!$R44i2XSe?$4 z@}E?tUwiT&UYDR$9N4@77K4rJ7s2`8pi5An0&Vh_>ifT|OAv4Jm*^4;#4!J9UHaEP z;exFPJBfd9NPe=3-)+>-PYFX4h2PwOeIcNP*88nc;`emnr;hx>AACF#loRUgpdkVDH;q7>{GTOk#zpJ-dm8_B907fk zjfsf^^w%c+_bKBqLIn0-#y@|(8nHp84Kh)`S{M7@RU=3+{FkT^^!V0)tww*u&738r zV5aCh0|X9A{`0ZPf31H1(LF(S>0eM&$O7)43i*F`1oP)~5Tfp!Oh4Ck_FpE4f4$ax zZvvt3c)I4r-Qa=x$FLHqsJs}2j4E&3mDIA_%9zxW+V(tBKXS@(J9V$t6!Wdy;sEkp!<(nY`op`BzFP zhhPtVLqh`>^QAYiz?ZN07|aCS{_5qAFu<1&$EeSYhX_{*s391c36}C5{4*G=`*5;8 z$XQ`N0q{kvIV8QGuAY~i-}d>2T*#W^_KH3VsNaqCg)si?fp>yHu|*2j`$iM+(`R+B z#QAtVevzno{9J+X;ybQ{!0?tJu#fX;P^P%TeIkRt-tpRfQ?*2pCI_?t=#GJck41|g zP=DR0MGo4xv$vP}3Fan_W=pvHVzJ)G?qu&ydxXdg|o9aDRaJ_d*ecz&wNOLo(y4_Cbs6z!fV|5 zJ}bm;;78&gGtlI>exD{YBG{p}FW|ASQNDw@(7}{ypzeq9O2ck;1s`{#?~_s*1`I}v zgCrLX+TtRcM|V z(~ztk4%@)*AriB(sYqSzflho%0?!C}o(b#&&8GP9r>)xGzX- zaIic55na)n{B$oBi&<4wYP6bjwa}?ui|_3&la{NBcvS+A9qri0T6`3D0HgA-tI=_J z!9@7oYnw0BZC>ZdJn4jgCMXK$(G0%@M&FG+F@W{iF)!Nv?FLm$1j~e`>wc|h*Rt>J zI<2|vz=>2{t)ElS!`?0)F~Xf4M+6~qtHuP#T}xKc|GF1HQfAJ{V%+{HbviJ`tUZmZ z^bkXQ>xNXb^Z-q!^4|91Q)*|PC35}`t}e^lH$zS6TN3e|u|COx2(ak4h~P*U^ zTuEfY*0d}>dP#xD3K9DtX`jT=ELkpzIQT(gi-8U~7mrhBFOSn;-+Kr&ZjuHG@OKb) zFwC3MC$n@jdEhSjeL3!sQKC}DwNT=>-4dJ}>{s~Mny#u_@Kl$pVli??(YOV(oNRum z4u5Dx%a3+PRa9@H&QrZD$U``%h|*kEG24xzBH6z6J&^#8>RUb6m)fB#S=QQ@7N6R@ z%kp~3?DU08N#1jS_QCkZwqmBvy{=>yA`%jBuLqeNGA0c5VRo!yqg>|xd&eJl_qHan zzRD+2jjetv#LAbr*IZfLh}xcNGm&-4$$E@J<3qs+4C*TeY!d7~JCFS%3b)d4u+

<#Icg(&fj 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 literal 0 HcmV?d00001 diff --git a/docs/mkdocs/assets/imgs/container1.png b/docs/mkdocs/assets/imgs/container1.png new file mode 100644 index 0000000000000000000000000000000000000000..0f1cb26b7b53227802d09b1c4d5e49cce1dd4539 GIT binary patch literal 60080 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…h=5V0d?}qBTm{Ph>82dx>}l_ezwr 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 z zMV(8yQ`_kkj+ihjswva<)K-s6!9FfIEHK$03Re5P99Pdf^6RJ7Bob99pgg>3!z0td zU^%pDWIzy>Y%-X&UtcI&X`z7yXr<+A5zmHVmpZC1#q^ekqsYojT;*p~Ey{PfhQ3sg z6QT`QR%r1>nfjxaT-tJTnuK=3s4o?uD$C2?Cu9CYm5q*Rwo`s^l#L>Al_}l;Kanwqw{G}{1}G5pR1K(&p9EgYcM=$t>`g^|=$O|mcY zFs_5)sHizte=o3{-{Pkw4}#_N*9xf)e;OGK8v#D}B>!f|Ysj!w_=vbg`%YcD+PtwXuK{tK7c{`Tfj?dCSUTocDnc^z!b*|A2Z)NSOwT2F;c?`RZJ}U zx?5Xr=-s#)4C<^7n_wv8eDLWx^;cw(AuVtAjZE_6gBndyEculT<4e)Q0&(B_*j6p0 zemu`F9Qg2(1erEkGqk3gNFA3bymage!#p%kb89)AZ2!7-@#R5lw{mk-NM`zwUY70W z+X*1UBW=E5#Z~`c6tPPTKj?046jUY1;n!aNV19A@rnAw))%!~^k4b^$K@`gwh+Q^y zwNaQ-I$spkenM;)nefE(1b0FGoo{4qa=iS@-0U%VZ<6By#Uwo=OOWb_HQ&>=H zyeP^O%e++lLy6(6)R*|1LBppnf0N8jveTyl+>-L0UMu$IfRVssFT96x_9N0RaaNn| zTxLP3s`1AUm?~3nuN`!X@M!@n*6+@_S@B`*p%!WvQo94fhAqP+u2QN#8x}>^5o^ps z%}Lm#)NnFkE_93}vOBRbwCCrMM3Y~bu2TZn+GDM8lH;tivCl}G<0=JUYSoivx}nQs zyA?iaIcZ>Yl;(Px>!s^yRm*+j$Q8sHbMAoPp6+V}$9_p=a>#^A@@9JpBC8{vjrWd} z=J9*c)}3@SN^H*k_6J~yw4A@&k0s2c@h`1JrORFLKB0K5MUzpbM+U&oLAiPPn3Y3A z`EJUI`3viNuzjUDe{K&$mC(Gu#_~O3*I!kwqrC5??O=HCRM8@*@m=Nq{ptQ|gGl`B z{V%+8Uq(r72WA%(v=8$5RwDx{E_S~-e53?^y-o>`a%mhgTpfw;zQtKAOIW_Qu8tah zb?Q2YC~Hpn+KxZ45>bHx@e1Rq?2;!gKZK|WoCxkHxn;ec`oM%IPqr6zWm3Of)cuw? zeot*!Dm&RO&oukXZ0oaybOq|pv?~fVH!n)<5Ybg}jSRaqHET~TUdhy6^fbZSy;q34 z%ylK+7?Joz+$dVZl^(@{pVpk{qY3Zz zu}8}-eilR3udjQ`wHSx~>Z`x=3!AGoesG>TPEIkTE85G$hRY<6k+yc1keJ;>S{z?( zu54eO3^H6v17+9dARY0Om<4%%Oai6Zvwep$uY9qWOX{95b$)&+xKl|po2!3A(+Stn z#iCVWD_C7!Io>Is*CrFg*&ar$Fj&vs^ZRF=1$s=`2@j+Xk+s@dmoW2oh{xI52;`;J zQH`e@j~q$Gg1jkhbi}=qRkCmY?x)KAb+P3*Q(u`%iGi6~?at^iB0CP>-num2u&^~S z%G;~CBfAf-bWmQjf0oxNvaG_=4eeUQV2W9GbR|l^(if+0dggQB_hN1v zFbVJ^&xSq)>A0tIy)o$R+VfQ{pJiE!Xk+1g5xpY-7U)d+c4I{NTG83)PH)_|`Jwei z;#&1P-M{v6@K;Q~%Loy8f3W(Mg2zE9f@ih8y0th%;J(RLgFHxgR364|{65D1n7L?F zYQPMgdslWhb2X>`0C+IN;38Oy+ONr#pIU zaUh4n)gBxX$K@RPc*AF}tqp?G#Xiujks%mjRDqH8c|MxJh{InhG2EQ#iIdwN;p! z3ha$bR)0G1LBXsLAkzT!i0oJ>aY?i1^HhCkeIbKtntkIG0ieQH6x$eILcmOXFm554 zlkt&P#r6$CFDLsg-pu_7CH>2x^<<=>e!|#Z-VEV!eP@ac5+H(w1j?c+Ri-YEFXEn; zsrD|LGGw+pl?zO&>b-M`tyNo>&shhF@LUuZ8}_Zld8y$iqT8=aW=+4?rHbAi14IDz zrD13_U);4)z&8v`SGGG#=Pb-NuRDNL zsrBxS+9#!sUH_bE2kV+M5=@rc^9{+2y@CG)$#$G;C{Z)Jmv;Fs1 z0qD*$KmM-g@*y)@X%TvXF(cK?tB@b>EkF+7&Y>_HftSQu;3`2-8-2b&#6n?9*&??uYuvCF*0IY{$QpxJp;FzRvq!}q<&g|uz%iOc00GhGUnu?F2 zufX7d85c*YpppgWuoQXBEflalKDjFruV|Z`he+Hdji&_OVFAW}8zENiwXWRhL>RU@ zY0c%qsI>F@o2?j*Js}b^%{#z4r<}t3RYQQ*r>os;-y{S(5vtI%d*5a$#`HtgXf^Jd zA(NY7CBP^fWBnbF4su`CyOu7Y2??C{#qMtSl`IFwH4dn~E|EYQ7-5|@JEfy-=yZF= zX@tEa8Hxo;27QIAgq|hX))g)lp-o2vHurJoebgjLQY(6P6fjx1ykY!iw5rxdJ~F8- z`|;%z@$X+R!Jq$Z(oCFDAdZtnPJy)6HoL{AQlEPQKPIv}YzG5{qsUxQU-X?eY_8cq z?q?pkZg2aH*N!J$bl(*|ngqOLcK-JQa|BmmQ^TVL+TjRmCEcN6%;xUY7F1v2%)aY6O4A%Xq zU+b?9V)kJCQ573|-T+hGtakYC8oAEjoTM~lTu+_fi-$6oR+twjq3~+;#-Bc^o|`E0 z#xDHz82epfa^|1 zg*9TP+f~iVPnIs%8u|}7N#R$l>HSyE3&_=+`Nz>2L_}LX{4}L0Aoh5NIrCkLv;iw; z4CadTK^_mmNf)-vE2>>1x|N@z#H+Nk;^Dm8SHdG{yC~_zBSs&JY{#l(n6x#!H*mao zRO{4Qanq}?0^(SF114-!Hm8}lb^L%{T4tco$-_B?05s)0x2-#poVAQYV_LjX(pd&v z70u;OF!%Ae3k`I_>$8D}w+q7S_IPV@y14fK=Ac_0Io#qtFgxj^EWy+!69D;~jl2(6ie!ILX^>w&Rv-ymno`xtyeUI7*h$-Vh2}%Bb3v|&p zy(8@O($)<7T5tIFwy);8E%mfcjS4%dD8~7R2-2>Xb>A(~x{D}#UVJs1wG|v5LOa?6 z&4TKapTA_O8QJs~eyKcUnUVU`U+a6-~W?Nb5}Tq;WSZKLZ=*}seT z0mOocjC@(3Jqv9FJP_N(mX)+wLUlV$m?%G94v~5G*D>(l_JpC4bGG`VqIaooo67y9 z$Qbv1AGD}8wWtBt*X6^TQ@(HEPz#=zY*Z;5Bq8UPFI|hggO|N|dTL4#DsH^$&wc4q$YaRLVroLBPyR}kinL2Z=jKq%;N+^p~K1 zNPd^>@(uM=+?ZPN(n##d)ffZ-G1!O26ZFSL^%q0++fpUW@aVuwB>P&OHoiHc*j@V5 zrd>Xxpe#|Bv{^eLqGYDTOGcB`ZWc5PC}F8$5UVcQD`V*8maykHiRs#R-kovCiQ9kMm>$cmz3aJ=zq*9)s~j}4j{Gi< zhIeSn;LDu!eHUQ@JGo42$RA!RHO9faI(*yny2tLtZPUfSG;%Ldv3BWD*Z_pKGKLX| z+2nXc`-W$%*&7+-8vPNL4+pQaZbG4U1=8WyRj4ah$p=Hx8wxonqpbY}!`*ZkPsV6J zlcK%uTm^0qYMC(_IjEF&d!~hi8tO6FD6xT?)QKDpUc3EI-F!RU?5u<--6B4NY= z?-`D#hx@{HbCJ)j5R2XN*vvs(lYwKI3{uXK^5--1nVtoz*5J}Wz3xu)k{NPwPPeb9 ze?4tprw`j+>P>Swk27vS?3(Nh1P(@-IxeBcO@l}CH)VO`;4FN-afwG2?nbYa4OZa8 zEe8ci+mb&ne0<@Owqdw@vz_j2-P(d|>(4ra24u6ZX;tA;?MC#5c7JX8_gANy%&3qd zw7%$6spVH3PwA`4th)2CoTLsSB8e8cZ*e1h1ZLEsox8@_g^+czm+P4d#qWN<%b&Kl@r%h zte09o!sJ-b@DH&9fk7+j4+fejC{#pO0~LlI3xOx41xNsLTCN3p|Z8waBO$M zkJj&Cp{Yc=n|XozvLshs}2y*c+_EZ?MU;y&MG0 zycn-ddXKE#wVw~ZIrX-4R8LEY?7(^f#Uxnj(RoNplg0}D-3t#7 z`259j4(Pe_C)$-oP|IB9_cWG=zCY7) z-jY}VJv(6F=yP*{3XksUi+@RHx}6Uf@HLqfNJT~JS$0OKus@OVTMOiicyXv-vAIWW zRxfnC4d(GMF7;o^gp4Ea>$v28||i>kO6FiJrwgp zUp3g(Yp0=)H;=yU8fnC7Rbwa2_rFvaWV;?#QC=qDZZWBt{}1Yoe~E$q{uhK++V)MW zzitmb#w&jl&h0}-!v3T`%@H6w_d&pKyEN6YcM;qc4suQ&e^I}2!do6np?_{@GhVI& zpW|QTc8!PFq0Ux{Kcr;F7Yj)me)ppIEeMtRv`LdTl;&+fDx*!G?vupY0 z2hZ;nsVct1-L8(lak$_OrU%X)3|x>6CUi2c*%3{W12XK{(I5uh z1*93}B*15=&#q3enLPVc49Y(gObC+MKR^^i0}!kXmy$j&)?@72V=YXkjc;ORNyqn= zpIy~f2$FBE{6bb)2{8(E0gBz`k;jOT?ppL<$((i)bkbHkAfElK z6PWh;e5`5U2jsm2qI@|12vh)b$|Qh?PC+iH3KjKvR5u!=zf=M(Y$&;wpx9GWIf0NL zlbalH0TI3*$#IocyE%s-bTJ@W6DNRX^8QSAb>BDcU+8t>@N_)rXjebD0}}^QR>3@`zag$^_T}gg2fpuqEv?9xIB~ z=Hkc*w*wp5wb2Qxv|{1Gq04WR%-Uc_KK|KQVBkcx&$?o>1=4r-E-NCV>%d6}Vw_OLG zo3<)!O{Jf@ZZRXW+CKGOWf5}aMF81LSb?UZ?7&LprQy~b>v3cUE!0xBU)TDCdtqDa zDi%MNZJ6l4;ysY%kEh^F{nx5xqN@CADV?ThZyyBSclyQZk}xbEBdSqkFpA-{g7P_x z0{nYhV|$y(Y!FGjtcgsIxw0n^mI*SosvLkt}v9 zG$2O)Sku@%6as}ZI7n2ybfQuS(F`}NTcp^nHBzK-8j_Qj)OQ|25gQq%ZG1s?kzt4B zAnTL$$Ge8?sZtT30D(MO%DsF2uEfw$1xWBPWG{Pu#AicWrylZ2DF9I$>m+@{C;GJo z)AoPfmZ8{nuSGQND}3A;OyPrD?IIGd!o1&XwJ07(Eh%WK4h!?#_s#fau{%ZKo!QI5 zkw+pVyN1s72(KLR=nqXWwoM#LIgD?M_57xI5#DAY3$4|xojiJEoB^^{0wcq(+06a%eM z3lhO*QAlk|sPBFHdNzHK1|_v~x#wQu^W3ov=t0sSyQp}knl+lr5yuhmW{rOx1iY0J zjvGfS&FcuJ=B)>Jf)z**LURMK)Ma?Qd|iI>hq{v*+DkJ+kI?~!Zk!$ePc+Y?AL(N7 zc^+#4=gF{jHX-#=uvk~9_6ORHjHG1PK)Mp}D+;}J)A}0wXN?bMwjsZp4Al+L-1E#I zssVD0P$=>(K&SF7^wM$MJ1p<-@qlL%M5z0wMZ~@Su68AD|1la-IgWTS1Xd;u+(q>| zs}@1Y`n%g#KEc&d^9+$fn9LNmI-dKg^4di51^69Fu55c!0F&Q^^B|XHy$`^=Z_V<` zk!eC{5l0s9UXT0--F@~iy1Ssi+=kVAHHH%Xsc^2U6{e0%8->Z1&yRyHBk7Y4(a%HX z2CbWBooqf5Y zfupfNQzwGIFB^DJUMIi%Mn4EhrgbguXfqmTz}_b&z@-&5(2Ux${2T?dA2 zc1@Jh0ian?-233#P52Ax^R+e85LrG0kmaZSQz(M{Ft3STp_TiceNI2g&IOFU@w zKm*qQ3kx~%$>nS$XceinGQt-nzp}}|c78B*jG9#X{b|DF&dpHUbXHwTv)m70;|x6k zLL&=Lg84%hCMap<09>8!YpDnCZ4!d@2C(xeQKsWDzH0ib9a!M4y|u@O9^F_yF+i6- zcy3Z0|4eT0cH-Wc8;Ci!Faj&X^pcd??^l0oVK0f|gW1h)X_+>Xen)&w+^Q>Wu`g_I z$a-wIXuxGb1?h3Eya_g|zZ?6aHKH}`0wCuXxoLr6xl=<(eO&u0aSMQJZ7>0On%Kv~ zpT_&N6tG0l4)Mf;_{Ur6^FZBpVt?cW_C(6$=XxmmWF@)fMHSXsHQMCX&-K*k`-^|r z*a(k5V$(n?RFz$CTd)6Y!stH+o?7n65bMoAyZ12)3nnZ2sb2e}%%U7WG32k<89Lhp ziwAeI*)#tC3x%TO}}9hv(yjkJPo zI>R1iA+ei&KB;8kS<{qVJ^R!$FLd)kq|C(Yi6<@SJIn_0`jf=7dZowxANWGrgC zv_zomv}6Y}18c#V$5PCE$i5y;4SKt1Jej_g$Nw=-%5Wz12$O=?*D9c0npRExUvcVF zn2e%By3Xg;0)1AAm(%}}Q{VcV1b9I*wwJ{fUmnPitM+jlc7VZEF*+UZ(VG|-eCA@s zW7RkUJ_&OaZL0K}&05^X#l)GQ@G1c9r6JGRwV%9E^cP{oLVuaQWiM&=LD*FP#435t zP8(G$=8zPNnUnTP0w__cAkEju03eTB`)^}g(4@}wqWLRiitza0!jS0%b=9k$TV5%y z-||Gbkm;Glsb-JOml!_ZEk(83w}7}R?X*&~x_#(W&ZvloVk~Gd20TrjOq(lb8ANV$ z16n=jf6?k4i#C*;3BW)ZdAgZFgZ1kW$TKk9IHuCu&dlXcqSbK$jdnn)U$FLGZ4qx;`yGtqLuR=noL>j-yF~L zBK~~({87WxCJ}*jqs9#Gg*YmH{_Xe@)QRRgp^?0&BQBL<>;~3)+eMps9V0HR#`UbG z1u};5{RI85nQ3{g!YE?y+mJ9!zLvu%hjgjcLQOyl=Wc!J=NZIn+-TKR(v_UMVMf;C ziWkBgz$_?zx7@ofY8o=t(`i9Yv82AfWAXTtgQO7G-DQQ*@`1amhG6 z#=SNfJ0pl9*@hBZm9|TaoUCRb2UFKxX86aiS~sv90-=H$$;{7c{}lD($m)(v6JThM zc$IbJbjITZVDa}sS`h7GHRKmAlwy570!mv{3LkR=sTjuw7)*R-<5o7_iew!91=GOZ z$wK!SM|ft3iiDVPg@kMLym=rfXUS~l8q6v%2=rz*xXMJ{I^o*)7y2tqk?;LOvYXNf zsU8Dng2MPk{0tJs>2Z7bdnpRnDbsw01#$x{Fzp$7QA*Dt#1(8DN(7qr6HvOXl!HVs zCQfg1xUJ~xCNIs{cfaA(jfmMJ45ET5?vF_ChS|j$8Z4lN^U0QJm<)m3g98r2Jk@hJ?4%X1Y0-3%ce14*8c0Nu6 zDq$ong|9YDPPO-BdKaSw9<(HKWz}y)OFa=@G=( z^ycpy&v|Jb@YfZk>r1~iTas0wRHQ0Rmh3WKr^rZ zndQH>wviFat=K1?<|PnO+;Z!l(o;Q4d2ycV&eWBLt2G0{u!Jg}Hw2^B(3qc)K)$rU z^_HP+nl-{T$;xjo(Kd=|jL1SKt;a6B*}I%89&D!LS6sT|UA?L=|77Qb-2)1M5Myo(N(^&i29ll$z4Os!gV&YaYefZ<;&i zMBJA9J?0l850%($0vV*AMeN#~W8yRZ`Tx-Tz4-cS`Q$1-+%3uB#MhoRZ8Kt?=)rl_r`lZ)Y;j~lI%EPV7Woo3qPhP8Zd07dX z^(2gn;>$KVUjlWF#K-t6cH2^)G#C{s4@=^8Sb~_;^0-jM^{*fmG8E__v09b*xq0); zu$+K7a2k+WyVUBBI?A6QstbP;rF1q>Vxj)cUtDB^4NWMtX(D zN78oY(vMs}V!!;jhfmZWPK#3%0mkPoRg^Kc;WC%!|2)q1gp_{!1hM@}z4AYnX z*i?|5eIi`iA)&7D^gtC6igP>X#bJNFq(c*7UJdpJFM?(SUNbSg_+5!k^}C=WEB5K) zhs{znD%(_M*L28c^!yVSU41^$R0$qmpQ!O4*TpoCEkgL_@-z-TLiSi+PRU9tV2d?HnrsGx2+D|4l2PclK&hFZ?Q8 zH*rX+M`p}BFeS{?Ryd76<0@q5sh}bl2E|KqMRFQyKG! zi3d#sPvtFWM;h+X@t&(|UVF6Mq~Gk%M6>ckARX)zk}%C{Zf$fC8}yRm=Ps}48P~R~ zyATL^In{0c*u5M5tu?6`VEvK|(X}6uWfzjFvBP8qbNmeLJX&^_4X@Eo`Sde=c_9WEZaU5&c5^>_of2~rRxdf zzFa)Qolk}$N{%{LY5Aq>+gDo6PEQroTU)<;tQrEcO1qJ%)9xeDvHr=ykFm^A&s88apmdVvvlZ4i90=pw zU?=p*O#h8@*It9S=LqB2Ya_U6v6}SDzqJHHj8Yp9Z;4BN_)AN0iUMf~;-5L!Z5iMc z7w&wajRm?6DuvDSG(S`L|0+A{xTxE8-76|$&mwNg%*M2=Il{Qek>*T^MCJC)yBo5 zpJZICtnO_M(GQheBplGcU%hgT&>;6d7bS0J-gq~42Yc_Q2pX0m74x)u2ltFC7S2BDB!$aHA0j8Ru8xNI}$Fu(O+4o{=T#9tAJaw`v;^8h@-c zW>gpFIp>-i>6%gq6O^Dn1QlKv)n}OGX<*Jm7bJs|$I3=peTw$JRL|$pWn|->PML+j)zgCPmyU16I`5-=29m|`3*{XP&HcP^bx#N{)t}yewttT|bNgn2S z(}A#w@`J*)VU37E^#lJ!BX97Q&=HG{rI3WWB`qBfG##r3e0v-c#+Y}@*rxuR)A!$r zEMfIe>IY#OkMTzIoFJyS4J?sBJ-Ed z%Nr>k$&a=xFv zi7txYE{o1VrF=^CMMNmAxwPOh%#Q3l*`? zp?EGh4e{YJy-(D=-X zE%2p|0$(cc3p+trT@iZAf(^EMs-R4rvYxS7QFNZtIk~RPR;QODgQ+IXR$UV;AU>%2E7t_3GDS!WJTO&EI=I7q? zm+7B7jUCk%;B3TBQ5`;Oi}SHrj%l_QqW_Bu1CyZ^Ekc+c0JWJX`VT?`xvvU0i30`o$dVe2`YfDJH6 zq!i!?S}!(x$%E(CdAmEt;cjAm0*lr;S>{_=oQM9Ep71c_H4sa#H0)85Pq(GY`Wed` zf63D?89MgTs`%J8Yd4_km)=Jdx{6c+VV!_c>blb|Z2e930x$7RQ(|)gsVm#c8!Nh_ zXCTZ${LzQuH{twaM4-bt8e~BHjsg`gz*KT_ZoK2yQy+L@|)Q;ZU{G5e7 zrdt%XxQ%>>p}oU8DWxZ&)|*)xn=scIpb zDKQqmK#A|7A!BQ2FnM1S``=l_wiB6ox6$gjq&OVcAE%3X#fJl`qII zn$wJ@9w%^rb2J6j@`Avb)2@boU6bA-y)$&i+2k6w|5E8mpgF6EDJJeQ-IxYP(08KZ znfi#H{i7eFr6^BN&Rm1T1b`m{>zdhEu2q+H@obdCc=pdDz1Fqm{keRvipN#c+M{_T zM?dP(o-p&ml^UKE)8z_aJIkoY@7||Fuo7%iZea~BYH_X?AY*C`6W}4XLyIoU9}~?> z{#pesMZxCog)dyZ04re;xl-u>J~4Ph(N9Q&qJ|<&|8AIr@;JBNJKsx-toJMP-LNo# zX;~-+EnB$_nHj@>_!fd5+uCP_;5ZvMepuqQh3g90xUDNq-cjY2%0dI0HOl(Ek#jUM z?w~okh)L}GVu(%B%MDqe6=NskQr!=-Ve{@QW|No}%QEe~=PurC@kz&Q-LyU;K}FDJ ze=m=5C^+m9>*~91k=VSjom2o-^JEmohTx_=NOt(=Wh}&OG^7IbkuDA0%%Rd;LxikTD961>L2r$-!c7fB(T%TJt$4rp^!hDxhPxVP zgy1?P?2d9g_Jq3{Ff?u#igV4qa~K{+l!7vqIqrqePVUNc(B9(2ycQd$iH0jpf?w7~ zE=NCjJZXW{Hd-1>-1JG1l(a~lHV)-n!1;fqT&Ny3+(wxco`|HuK{Gm&a!#$d`J=4* z$$OfU5ai3nC`v;|y=qP?=r_VfpfoV=<~(YO-B@&%XdhmZP7?pI>^o7F)Uo#1C`)gO zJ$J8>A&t!ZQ>%>r;nvX?(xwftBpw1|K`s+IJZDzD%q}_6FiH4CTX4%WA6|UqiEebg z4}ZBHm0p^un)Z*aS-ank-DH~U0oa;7(Icl~3(J$Fr-;6cgaqrR!ytN;fq1@n$7wlM z+bNc@=Dup9s2IMGrf!g9uZ+^`pcbq{hK|5oP5|dfA9Rq~@H_`O8E$W`aDgrVe0S^n z3Wzd)PmihnvNnF%{v@~4RRiqnf<6$@GSm1Gky%{6cYk*xOfWn&f}s>gQu;vX5LBNb zc6W}?t(a8Z0}N?p6U`ss)vp@qBP}^{jiNG&Wz?M9fCMwBJY)Gg=!@JosIe>paF-p2 zgWZ+Yk+Z>afU#Qb`Wi?YbDvSq;>!f9B=)dKJ^-?eVvXoe6++E!@9Q@&~gNV_#Jk<{$qD$P!5t- z04X>|&f8hFky}iWq3Z^`OB5o@{ZjGQf75v%0ku()_pOD8wE{{A~XtcK>qYdoy z`y0QV!DZjGZ6~*M96GQ+8VO{C4 z9{O#9oP7po5`st0N_!-k<~n()z|UmP2#dX6gL3D?ViXdvxV{TdSNOOm0YmdN?#ZgH zKg#Is@pq!$8ZpULTgp<;QgN!EpRH+hj2Kp3j~7VF=8`r#`G$ZyO=B^xXP+=+D9xu4 zu5`iHe&`=}qk^P_flAKOrm?AA9zQ?jdP_L3#FS0bfT!THo`~3HGSGhh_lps{H*XvH zr6h^x+_`&>3erzC$+Hb6*a(OEwS!pJVTa z(=SrNQ*|Ztw9)e>7mWPr5H2^C|7L*mK?p!2+SdBwG8TmW`5oTnpq(u^R_#cYJ;B#N zivS05*UzF%hB}nkQCZ_VtE66fbNNM*e}1SxJG+dAe}S!Q7-v1 z$lHjpBINb9pIj5!Hq|#0-=vxE;@eXdJJd{-P2G!q*DY0)_`I6YoqO|H7nm-2JE1`1 zDTg+9+lwrYRO#YJ*uPXhIhfeT4z0Z>k68^hg%r`}5`@1lQqH(JRx#~W(Qh*OU9%E( zhQ_s6kQPSfcziQN?XMkXcu-dF!<<1q=V1g{zz z$j&}M6|LP34`%wA{d2Xq^>vD#VYz1sNYwrYh^y^VW*^FA?=~1X>n(dm5sC@tvEcO_ zGu4>F2Jg>WwWh0RpM{hNmlSG45AM$MepOe92hBFi9IYJ}@z=TtX?=nOwhS+a)cGKq zpiR%%Z^J*GfyvvOs8rGCcXPe}5&xV`<6`q4mjr+Qsyf?(9un`8AbPp%enDpanZz72 z=X{OOIyYQ?~5WvU=TE=04Sy{uGu5!|DJh_K+1EJg{|c4 z^)~!;;xe6|5am*WaSH$2JaOCJLNrTVB@1$*{Gdy|TU&iaa5V(SyG+)337cKU_`B9b zG2Oc>F|zUPO|wb&Qe4rjMg4Ra8E#5Q*2#qhuN7YWSbE6V@mW$PP4;!|&U}Z`ZJVO| zJj~uI))+03pR>6>6JiGGE`}Q~BE%DL9X8xxjwL^Iw&@B>!i4EvzTRpKUr^&K$a$sA z6;^h)1L0z==Q#eh0XQcDfrWz2p!1v&qyt~yH<{hgMvb`xzh@*VUuPqFCC>SlW!S2@ z#@%cqy;qKV#9-2Wzs$Lt<4g1(?uj{pBi9+rt{q?z>>#`twqCT@wjXHASMuVf2bQ}^ zP?CjrSo^jA@`W(x@+jlfHS+%8^FJ38YKObchCQg{UJd?o0g)Usu5qMc*&RDdN#}!{ zE`BQ>1aBBY04!77Wir;lMKw(?Qk)8|ZU0~+AImF>HvbYp72-@Wv|L*!le zt~syXeDR7qqGSc^-4GJcM*fsfSL2tEfp~aU*obr+|C2({bwcZ#mu6z65vDZ=Q}p@S zPpUO6C_A*f>fS7hhFw+F5LtCk9ozFO+qDH~Fiihc1{e<8&AwJaLg&>HvLG<=Ons<( ze=n5PRTS4Ww|nP*-v_wUN2M=qx02eQig*`zk=g=85BIB{ooDKMqM?z#KyoHjAhf)UFz+ddkORUt>ngOkK)A zB98DFb@#AnMWdX>_-#%*m`Trd6{_kUM;2~VJ+C8#7fdiH)phC5T@`CiM%r2-=iaja zsXA!0Y?V=pdD#ZOFAB6ZO^RskJaKjotAPi$pg=N|3?N%Z> zYzCVIo6|Az0BAZ#-I+J*@(+W>+tu;~{#=hNrUC!-G)p^85ku$RxCB1MN5a`8G@!T0 z_c-cx*p2n7kz&{jL;P(4FssClKPMRl(ipS6oBEoJkYm_x6h-{{j##~4SVZxORv;d1 zfQ8(`_r(lnN>ALm0>=?1v@@396jpP&0=Z+*K-(&8?|+V1D(txHSb@=0^IiR%3-`}; zZo=!bk5!WS*l|NvTBxL}!RxBrguIefAroI`XI-W_X~TN}X!Q|o@w!nsPKf0y=rXRP z{?Y1V9S_vxg6x&TLXoxLmfH{`2s}a0uEhn7>1OJ`kGnE*hyxXHGl>_@5|Msu(1*|- zsU8{OoDtUagTsxM7;>kv?~kSniWru#o8hHh-duC8={pXyM;P~Y8O+Lt?Y2V(*UWg* znMy|LR%HwX7$qDQ72wZ&M!lO-Fh{gjMdWT>CSg;c?nZR{OG1Hz<_0r)=t*@fzocfqgd9gw7w_ zD|I|iee57FR||!fr8YuefL4XSOr4T9zd7Lt;c1lUfamNo-78Vd?`DWP+u411MIE~M zEgFdM$_AE)BQfST)BUnx+zi<^4*|y%A?L0hU}EdFJFQ{hOldf9(z;;Ht2b#6`&L`;aw?mo4kNk?>qbK0@n|Df{Z2(iJEo` zh#)2%k#|C=n+_btG>Rx}OSjc<>}>g1ji0Pyy*ILXBa*l`p)I)Zs>&fmmL<#(ZN$e- zXKA05u(vO-lwz=kPyhyirrYARKFXo%R)^|{<240_+#hfJsaUE)7)-=)*XyUFUqKk1 zs-4%yRXj$&L}G9Jtta=5e)1U!M7if=gn=48xTW&R=aj%NE?$o!%2%T0@MLbY$W*0M ze5}W!@GsOX)R5jrhKpTK;|S=0Njv!UhkzM^;rw#O_VN0*D8VU*>KdgpSP#O0b1a$w z#J#4Tw^7-m?EK?pX%Sn>Rm6G^Q3?f((4RtcG3JY<#J-foLe?Xo;eSc_Ykw}e-=a42 zpkGNQ_sD$1)c^32Di-&Vr*-8^Ecl=O{pdhU$bIo`MzULbR8%J3MDU21>~+(*G2+GvmFF8Pbko|_h7>O~lodOh)-w%Q+B>z_8->SRVc*r`#8_#W(r1($f0a5tPx!Yz;otwIO&L6Un$M?0wVi+e!vFm4qYa{NGLelKP4fT86oOl}1iXz6V{7rl zl*nZz0LVB;4W}+XhQpoL*yCLz09qk7F-xUrpSEiPt0Cg&JFvMW2DYME6Lom%&p`Cw zO(24S`r}#n^B}#-Cg1ZJBRF&qem+V^HL}$+h3T=)(Bh+hJH<8!fH5I_u{;frF(+O%77O${$(DR zE&(I3lLa~bYCnr;o<6|fX{Q1cul;0?H$S%lV+75B7XIq>MZs6#^4zh9`^#UmORD=> zrA>Is%@Q zFhbF&*Yq-vx_Oeffoq-wA9W;Z4h0zr1GKVjP~3io5l{(`K#p53xBfv*GnCPR@GWLX z+M0i;(6wl!SR`6?fC@cj2C%47iD4-Z?_B2n`PgL90{9I+cIrR-y}DGxCI_Wi5AjWi zjx$fSmo-Jr)B?we%!N&mP&~H7<{Gq92Y&;u3pKN~vtSU~@F<(dC_p#%2OL##+5tSf zy#*kM2E=OYXMyxo_W%})t3|B@@rBW!X`tp#4Ng=UJIUaRpbC_pt7L$h&88kmr!j(f z?R1b-26m~_z@a;a*r-G!P-e<_c}3)RIDP8?rMf~5cD zhx#9@!E3iG2Qy^7@+z`w%=kD|dYK*^-m*>nE$m5KlpD|uI|_RNA5KKzy|DDl3{KDg z7>Ln$p&YGk4Guddb-nB|{ktbE?>|JN@EAfMbA8cx`NCEgP=VFjL2r4hKt#Hzq>$jP zpjQ2ocZDzyAICV4N7087=mSldo&9SfEFi>V2a3>MJe$Rr>780M%PW<-d)3 z!S6fw?)-9;+60L08{FI!vH1Zi5B~$PJeU(E%rC{EvBr!KCTt_>}TDl_czMUVfGs!rjv zRCoC_-s4M|_Haac%4vp7IQz ztBIvQ4MlN*UBwp*E`iF(SE*CN&W4@*O$IXq&W0-`S~#|_oP?M62~7v~=58R7yUl9=8Un62B zVh*A3Q&Xel=IwlO?k3sCunX;G((cDkq@T8gb(^@HxK5Rd*3@q|Id8b2e(#*cOgnF+ zBE6h94B=um8fW|7T{43_mofXFOM@l8y?!&Ct3vL0dEnhC=5Vkz;F@<;Eq#FJE+=w|ImL+|e--F%>3t4z+%&u<&CSi`AOeoUuR zelN46$@2>#7=l4$Gg*g??uD;G9B-gD4S)nx=V&fneIBCvj!wuSn}wF?ckr4Z z4Y#pBUFa6IviH~K$z(oz^^s*k{ml^uxp4ZMr}MrNd>C?`QVS2gl>wrq>hL9I&@<@cC`N{Wsb1)HPRKb)AAeN@O0 z_s#_2trM0}u(EG{b?ygigW*)tWnV8r$HEN-C!EYdVY7Rha7?^#Of`-2s+d@5+{;0MP-$r+^xpC%53)c}x?nrz z`0rFPcddGsINY>_eV{GP5<1>rz0)PgO`?d7@uwBUPQZkplZs;SDan?IG&q;J+=>~a zR*!M|Rj6I;V8(PKh5&s|i07;h{g8s5nc0SZj0^ludyd1#;0?dVd8s>(Tg7zJ81$t+ zN_1d32e2&_%nMQ7BJb{8P2+y5`7B8^r~Mvnvf^y1bIC>33ZpK*v&w1?xnRxhaL=WN z9(tLJ;d{qdxZ*yBmZg*D2;HL}S86x~TE6{kb=@3=CX^{M^@hL80D{;UKs zLaohQ9qP_OYm~*LS9+11F|0~6ymw<_g+3xpw6x-h+ZZ0Cm0k_YNnP3%#QZjweoJY8 zHN88ENs3ngOI;Vz@EbD8o1r>t^TCS}Cz!r;R>fI`!adx93DXV8MT!I&aw;_&wZ)P$ zoN6#EI_aDd_j7fujE;}q+OZx56q+mblMBGPQ#fnD>U6U)&~l zA0)Bd=#5|yp=VbVRx$Kq#+RGIQ^J;RD~?63G53U89d3+1pcSyzDaz2k#b*&!xFW;j zFPZ%0&QpR)%i&IPr?nl2Q?Lf9YPhZoOC4y0-^dpQ`AC^R(?lBL3h_CoFT~Z+FVxT$L?<+Oo%FoQR}ge>Qwa6K);nVUXRDChT0G zu?2@goq#h~R>XBH4ACcBXTlB5GVI2_#~tr3GF(xbyvY2qd*RK7gB2<=XM23S_k%g( z*+FS^j_cfq;6-TBL+oh5eBx8Q$T z(EpzMEl)$A#LBgQxHHJz;EHtlyvLkO+REr>*N6*zFbz2pkqKsIjnDkCGv8Lcc2reC z>6w4+%C%_48ypvlWU3i~*KT{cd_9k0(v_S&elF4Cx%?G65lrEDQH0@F$8Dvr&G4F6 z?h`7+?d7T@35dlSsW(q}$gjv;H8z~sCue#)OdXt1q)svrwHt)FU~;_-38#D$$z|6# z_&cQ;GQ{{6&TPPB9J2?mGE`9^*?gxURFuE@D(;Rie7y4TLztnnmo)_OOe@Ve1&m`yXUL5`ObYCb7bSIc(I0*%HsKabKa|Dfk;6o(IisZH zZeFC!2`EZmDmzU^i!%kCT1hQcqt&&cew$r)FYbZF-3{yApfl2`Y8wQVK9 zbhRbIpnXr0il;|jpONxTKy=yS21=2iCr~~WNGtmF{SqsDM#9eAm~0iKdU}_@g<;n2 z{yiz49ihE$mMow=e^ij4|M51xJcFL6Zku?OPtbF3m|Z>(yS+Pmzj)YDc|)8}dH7m2 zxb+Te0drd0L&YECWfqIhh%xkNNuQEe_nJBw>|bE8rM`MMP^Uv*x3^4Ax?eXi?qdt43Y*rhnPsz#r-$1KDl$(Jsu8LkX`!I%w4ET<=~FN-DY*Ze~J z+V30W%{(GG z5$@PXaBZsBIKV)W^1W zOy>w!U`xB953~pNwBEf%(b%-B3{a&-yV|w?ZB$!S?P^nqF32nSQ}1T;^ZQ0sX(mMJ z`^1stOCiC7ecgw-y4-o-J*i5`OwD6_aC@tE;_0yp?PBClqH*M zZ7!bMA_@rmb0BzlsTW`IHaxFMfW2l3MH1R2E4b=PH;>&{&Vd5rbHxx$syYhPXWWLyMjYVsdh}#l>*fgmqg=8 z3q#2+_|Wl*iI(oaIveAsqOTvN@4|K2XQIK1GPv>$u5b>r(aAj3j8Cr)ZpNtjR!;g9 zW%u(j5hUDW2@c8DdD+fqd`{nB^JO+k_Pejv6SYjt{p?H6xS7XlXbO<19P4OU908*NF0Df4shU-=vBMXVKltV;`o|#==IIfio}z2e9ZGr z^0MDVlb@<}0s<}vlNaIKeyB&?2z{u`!c?(wwsiuN391s}cv^B znEt!TU%M|pNbG;SqSiq1Ki*LOUJ##9T})05=Kp{I!G3m5-bxxe4`(?Um@=&G@v#otVC&ikA$B)6BiCzC zo0G$7K0r1)n82y`>fl>dhS%PYL6FP6{fz0cyuvHx+gF+B*Y|-Nw&QISPa_c*>+(*Y z2(Hp97cIql^ijRApdjR%5dmR$36eJR(*DF{Ri%6tJ`||rn6LOnl9>^)zpjuMI&~O$ z(91vMB#Gs8@_7un-?OZzr-C-u1u`bw_jJ}z6WWGh2FXy&mV0lXtJj3i~x{A=5MW}sO??S!A!<{ zyF#9IUyJG_{#cLdq+bljVY1^a`PUE}3LY7o4N{RG$R6ZzZiCgz0ILuy>a_OjIAQYY z_oJN12U`E`rU=F%`}R&~TzyDE*aY+2o0+{Ahc5D*_Z2W=>UAmG;)rtxa?W@zR^JCQYVy(V(=+yPe2^2P%>edMnJc7DdB0$1Nxd z>`u!WyCNZw9B&S>JJOIX*{JusAT*Rg9Mrj<@_`#&OybK*i)bt4V3XX)7w zfuc7Ov=zUT1Y9;k!XXVzxTr4i@h5?#kh$ky&#pyl57%_2Ng1Rpm8nWo^H}`8op^uR z+PCfs@?n-v-a#9L+ec)c}lk4u}jN0Bz@M_)SE(N0wrB{?0e>BMBD zfQYz}0y#xqyTl22d<1EP$ADBM~yhMYdW6{IVj@eIux!jocRVq_k5fl{@Y`N1cX3^kz7f*KVKtH znEBmQrR89?&Gak(*I*|xIWQYYXGmm^T8Y(6n69>SJQ1T%cU%SI44ewD~mj{e_EmjK0`M1f|nZr%)SAHSRc1LKe^kYYUB$ z6H{je&J(+v8)IWK(!7bG0(~KQ(AlEp=g{uAg87%x7f7V3T~1ZTC!l8i$?=-R!adM}!1z*6V_;2XJ zRy+*8;HZQpI1CymIBRbU`)tqDk>_WKIBzTsA*Pyg7%JxM7)4X2B^17gsXlp0xhr?) zKEz-CyInK-?p6W|)wxHIGCMw;`H3IEJ2dSsP*c(T%8+3-=?weG!TX*w#%Nw?%3$bK z)Ynk}rR|e}pTHA^99Oa<*g|pTroFG)1atZom+6OM55pwZbUW z-q6<)J)z=XFb76S%X&_O)4II)%C|m~8#Uhk>#X?Df-nE`FeW$r5PGkw9`QB(acRlw;q^`Q>h`ClI zx+r6k(oXPWO?KYS@!S%^cX{%(A?4=B%w-eyq2fhAOQ1Ztdr=UwDdk`YxDvVA@7{>x5L2*v zp~17}Fna#-Cj-wU516W{IdpzgG5tQvOLYjLql91Cyu)I<-}geG#G&gsbh@V0NMhZ1 z|J=9|#@sOi^zu7n8>_2Mx}IZCn>%`N&ilu7F1p{0%e~MJ4+Mk*BP2+1xYI}Pm)x_S z=1cy&^T~Op9TtK!by(@f89y~`cD>m4@nWhwb7{5h?8^i7g!)g^sW}GqcZr?WhdRi0 z$TB;aJ7T=cQz@I|Z_qB0lE-!oF{BiOchL1VL!F^~5-1+lt@S~dCiXB=>D(Fioiu}i z#B^7WLix{1+xEa0-gr-R{A<*udcRTq=20=C$z6XH&@WQbRt6C3(5v7~l8kD84~yj@&H` zx`K>G#`Vp~ym(Je*MWW^L;f0=J-lBCcc(;mKx${;?eO@6Rl8=}bcgO^Qfch|Oa-3x*_T|s#z#j#fXVN8-uRr`BKb6C zinN7-`?od}lq%%>$0rf;h5oNIYytFtjE3|T!2IWEjF$q~|7;^h{c&5&*j7K}gzO@( z=MDu$K=;QNTH*bhb0{bYCgMqT*MrFncL=ssO=ngRjK5hq}Zr8*zie=R7CT& z5k_9X*LH>Z+G@S=&!6Gw%8_VjKau zmQg#V6iP5}B>oo;EDUIr!2XS4?Y}>hzdODEekT<@4BpFNSOQ`S6msIf{E!fUV|+r7 zq&1-aAFu8|xiL5sN&TJ_SBygWe_`1rU^cJ{&iTpW9m4;f0R8t@_#cckr;*rjR=)S< z{9jmhDC97ypgxd`Kt2BdRH*;SvP0uUMMH%Hxi+?X|4-omv}OKJ;Q!%f`9FdGN7exg zBRF>BZU4VU!B^|U%p0;KcSXssG1ENQf8MTSPOglVPwzw-;msgU2g#^X;t00uZLcdP z!H!8|d?ygBGuXZZu{YS8c`5vbu43nh7zSgfGor_TgBeF+ya&h<%=y~aU6w`F>Lk`uKBZlrj$-wAP_e`sS+cN~ z(@36Zhvi7J47YV`23L-Fps{Mc7#o9XP9m*b{BUn5;>{RvC&t8=T8cn0(8yRYr7bFF zQeRfpx{{TlLFe)y3J0piN5>NKdVeS)`M)%(krO2SrN)%FhD<6h58podFMI!I?VedV zDmm9ARmFwFlpX%xi=xSU>&hbmmU)&UUh?%;WAw#}Y02h4HXDEPBN4#$2)b?mtmz9! zJ+i}sIa_J_(CoCz%%GNsCUjkxYSZeX&z-wiY$BL2Xmb^gbuWJ^>m~p~cjNoupPxIK zdQ>4jZ6w|Oer!uP$GrNl+$IS{h>T>vH?LCrSG*?FV1HMd6LAH%CgLf|Bp5#i#kElJ z{eUbUn{<@f3WbAT#VIp!%}~U_V`(gR7#=yw$L@qf82|ta7XVuXQ&(fs>6R&!vGA^;^j+1XuZ6VHcd=jrxlF z@cs*oJVe7mO*`jFHGDg-P70}Ntxcv=QZzKop^9ho$TgKGzUfq=V$qsNPg-6h&;0rYUJx>0*~3(k&uxxKEQ)%BO$Gw(_d$ zJ{6OAdekCJ+7e#b`F|g;wGips+Z5+ULQ2OD9eZn2j(nClsfdrxcyqM>)UEn&h9Ipd zNS5O?zii(nqq5cAXl3lTIIXsajCa~P3Ls9V8KW--_R>cOQ|tcvYyANamwCYtU#pw; z4=waxjsFW%@VEWd-Y^~%IvL(^N8Hp76F%33rD*R~Ae%{e*|saheIqA^fKBn}Lvckw zEp*XIF-2{>4qC6<3+OxdzX_bgG2G`2Mkd(Em7}+fX3zS>f}5~5*)0DqXBqs^)=yoX zAo1J0|BnZ)W`>dZOpwwI8!7)P+5b)Rpk8OT>B{^sjo9Blx%_$|7Nf4gH$$EI^3TU) z8#vF}mD(qTp8+a1R4+f;epgC9p=z_kDsdM7p{OJ>;i}tYt0yw2eXNFE95cT+e6P%r zko9n$hWs)+8#DlggDo&G_n$o|#2(*}@TIfr)zi1Vpb`R71RDU#?pP|F09pWR5`S2bF-pQxH%MVATA^E}d}8 z*I$@Y#!Sj1|E=TNd*kP`hC?wy!XMa$poGt9Ti=p>Hz+zz+m-7r9<}v=PH|QGb%x^0 zg|=nyikaKv-dl|x?)^Gl6}H*}dw?G=e)hr!tISl-OVry;VYh;uhf2_oG$~siP86G2 z_x)%WVJ)gmtKA=d{a^yeBW>k1b>wLS(F>o^mI4$egz{+HuD0V2e?A-vKOblDXv|vC z{n?|Cf45FTM`nK&Unwy4RWJF@bW^~7B$;kjz{pl7H>NGvol?|Sv)NH;6*O$Ss#7bJ z+Z0PSxh!&_X)09lb@=SXfXHu@;RRVYcKF(F**ycQ5cJ`h7tu_puv`=eT$aRqL?^O2@ z@%?IlCTbs)`W*xb0&StvRQ|aFexp|KtddI(UE0DI4m74&K?o*OT(^-1E<_YGsBh5J zyAOFf{9@-0z9kn{C{fO8Dtps~97r8>^gFOtVjZ>vCmfh5@w?<%1q}8)TVm=r@l&YT z0&-i82|woaF=}g9>k_2Z;yR4!RBhXF$xY_yI~W$*dHCF(1*SFF^b?Xwnu=V%M8c;` zI*g@@c1V!vcUiO1H5#zcia)4RS>EdVcH5kEdmgW-P`!_!uI(+xd3hJRAZMi;sXn`) zCa%g~sgS(Cn|ZLb2{m4@&byfkp!m*GYxlS-SJ*i5iqDL^WC zout|7N_Mf%(LVY1+_1F>B=Nk`?Db9=9Xnub?qvJ9#Uz1ov{QH9;*MkKM%BnUtsQ++ zoWBcD<#0CfqCTQ!`Jc>wIggfX(Ck?**8Ccc*DQ2XD`@Gs&m)K0sWQ+#q=_ z_+>|sT)48$g|f`;Bq$Jw<)PkK5N|3{X&8RbWxC9PtNH525*b)7(fjVbavXBtiwYP^ z;jbZhF2s2vR&oBARUN>E%@HB2u+GgOv2kYgf>8hXr|JLs(xnMYLR;FBoyT0&^6koZ zdh?(1J@h|I0_1Gx3e$`RZm^Ig9Bg9%UQ8RJ^mF)Z+J-SnRPR+#r#b4y)&fA3WEYuj z-*`&Zj%;A)?Rq(hPtqy6QhPpymAmP&#W2RpUi*~`V{jF*f#YT)pC<_gnITHTn%Ub$xNBati9fe5O>Wq2B0B(InElU9Ltkv}QT!XX>ul z-m6&EUBBeUxqkg_z&Fy$MZ1$ZO0S|M7z{D%?ilSZ!4|j}u`D0yAeOtjry;FkVC1pi zB*8nJ*KXD`&#GgFNc^cQqoFr}$7NVF{;c0n>EL+h+ovqIU!7K@l0C-v@18_pi}z0Jo!_{~;6l9d&j3Fk*FVqpFF}Pw0CMNk6A(UYC>EW>7f9vH z8V5&%?X6_R>-e=Er*Aq6Et+sf8jS=w2uj-a84+ED2Ca+afXe;s<=HQ#>yu@JbcrDm z{YH*hnt%_-{(6PoyVN?bHE%%;v{JDYk3-(X9VLS7)vxTN{EnxVJ7EIOPlQE{Q5aKD z{uzxObwzKT=HBJw21wH{kT17{FGap}R^~eX&Yf0*2-^=apR%iIPNKd-g&=q4<;(pk zvGg1JEuKf31}RFq@h}0LsngzmibUY+4f2C%RWSuvtgwGFLV!G3Xxh9 zvFCl!UYv-kiszXI+%t6Wa^N|q4uWex8$?hKaMMM7*A8%=C%;^DDZic?bbLPZHJl}h z-zS{H>lC-wshRO`ox&gi2W^k1DY@Iv-pWplFRO{c7B)XTWdZ!Av=6Thtaq2yF2EqJaCJia-28t6VfaD@Oy;wR4?aF z5$qUrd~l!B$5$Ck&C{Ji{6L2 z8}iYCnZMVQmJmvr@HsBMVJJd^^JD^}c_N}VW;A4>Fyt85!Ec3Dvdef?AG+_3<{J2} z3QBw?;Mi~xaJ#RCg!HPMYu?{cICYv0;YHc659H29ySw~ye03qtMkR`afOM>MS>k65cdAZCNpOnk%tb>IIyFx>pF73QWVCAZloB4lxC$K) z-E$9a9Ut$XcZuE)r(Et(MB{qoCG>OvQv8pb}e2QJ4uE?f3?E@reViHIW`HbKCE zE<2E~vA~~a{+FShp*{?QDw&r@Pdv=YQZG7AEfSfKx5Qw=^9u6@Gx7kg_kNwYupa%o zWlQd<4k@i=$4b4e#lTp#_Li^QbE4SnHSuKLN}L972?-&OOC5b8hp<$vZ6l{XkhUUGCXdeP+l`E5*`$NQ?9ne| z-@i_|&f4uC-XC{8?pD@&shRV6D(5Y?`=rn&O8-DW&mNv~r&W96KBGHKpcEd(el{m9 z)S@-~;pxkxd^nQL0X!w3f9kv#MTNl_yggfH$@jmH&tudrSJZm$3~7Dta0c>ey)&4r zGSWF;4BuEh->rQ0up$l$!s^=|*Q?(b+83r5TJrz>`(|I2{z=CGp0xH%^hqozNQfk+ zQhT*L!l6_4I_6!cPR;8XM6=Wc&Y!82|FX($zeO479HbJ+6XkKmc}p)F_a5ec==F(T zk^qgz2QSOQ0d@3^!y#4C1uUkx86W)JB;HM@(iETRx#;`)PsF7$OwXziZ-AEpm<%9Y z$SS@d&tJLgH0z=J^Q-%oDG|PG7`ZJd@Vm*VLX&CU(|184U%MtA^IZl7c@J?H)Ui7Y%Qmf#HNpfmS+XcNOC;x(je|+=2V?gx|mq zeKU)0yVtfx*dbjNcH^BNGEPGZTmld=k}~ohGYGD;(zy+@dy&&KwW5P;6_8EI7}=iT z6sCrguO-$AcGSF2OWbG*ROu`lwv8J- zA|6`8v1X;1uk?UQ@afCBHJO{I%ij=y=5#J`m;7l*hjw&#NdsK`$Bf#nfbl`C103gX zLt=~aooKukwW@;{ltOQoTO1X@OYK^eKBlkT-ySueS3B4@7aY0g6OBD}%5?Q&0Mq%% zCb2lN(WKKHqns2SpDx}#D8mnjnX#5ZTm#?SR|pOvO&@geJ6p&j#_#}TA%-gToTERU zT$F}G^woK>PDM0fR)F)k(>I>=$*0=?elpva^mVpOZ&_~-<&VwyEkfc6Eoz2r#NzLu zA^(o@P@%x^uYO8daUCLm-|6kjq+Sq2CJr#XJzk_`(Wy>-ecBUuJ6_-dXe6S!s3UwwWYTvwgC*do<%GAS7hK6gdw{Wi!zJjK@?+9`ty7lEP-tJgmgg z_2y{qdqbu0QEU1TWuT!j^jwVzM}H)y*X_f1JT`qzp-=l%5bTmLho@uoXH&qVr(V5f z^wafkHSPfLCt!aLclIg-Zr;hV$#aF=aecaKU^gG{CD8eFIR-Wa+|Z=m>`zOP@Wm2- z>U;qo)%#po>)e#)1N7_5m354z5ZILd$|h-K@AW(E`i?2q#~h&yd|Qa zJJX1NI{003|G6bmrP}p3=_e!UTH_}3He*b1P50=4PLr>{=MUQt-Ja5@o&iYgOUzvF zM&4V!QJSk-%4(9cx({t2n>IP(bzq&vk!BsX+naE)ZuxN|4TI&z4?!A8_-sqW_!o2c zsOm4iYZJ4VsyxUF+vKbMOF3R*j3GkR(-o6d3(!O+0x>$#t3m84m+l5uAUYf>?#^bRTsu|Hux+$Nd(`Ql_YN*+SotP z3;JA!9czLDQ-CBK!`Cb2%isU-3|tmRnXvWvUeLVFMg`yf9rf zSv{_ojQIuYNR)KYeeP4M7qBB|PjoFIA}(8S%IU+NIjiegHd+N1v1&Fr?o*qL6;-q; zKC_nREqFCU^z8WTn4cw6Ao3A)k2Au)y0F_hmbi`#LvzAvDgYLqv14|BBGotw|JCQU}Z@!JSpbh>Ja1+y`0SvIR z-X?*t%zE5A_v|hLJ8qK<3&sx}XJwea5)RvhJRW`?8}cVYCiIY4d|Q-t<RUm#h<23MM2fN1B55Zc>M34JG3GupdEeoDX6*cXh5k=KcCZVQCC8>6!*X;} z^2G{uDXU4hKLq0^uo3R6Dp1=eeJVVd?!jVJv#v>95UmxX5NS@+>=0e46~V`Az0|2e>G)g{ZTR)L6IBiCZ5t(5vKURr;G@2W=@4fqbL78iO}wd?^8V4*RRixnW|b?Z>SB`0~ZF50dd zmqOiYmuQA|HGaTT?YdgfKAqmLlSFHyDp)v}6ypC0;Alq1ltCgc9PvoQdzWTX#Hxf1 zeV?J2#$GvOd&Kwg8!Xf*h_Jsk4P2!YU0DL#+lT3=Dayl%5bx@EKkv|gg|^iWtPE_9 z0#%1{i4w&(1z?w8z+73WBK5>6AB>3RA78XtDG?y9)j+`1_gm$UHwz;E*Y^%)B0|x{ z>%Lt>)>`h)SUFJ8>|Cnd_CubF%^oqgrM^dB91We5Xk=!&cD|{9?K>292j2EOEw?Y; zEluCq*TiLzZTis;qf|7HYL$KSZ-w9PJ0paXWa2c&1UV&IX@i#M%K~rxLS1b-U%j2Z zP8X<2XRq!DE===H)t6Zrf?47qNS@A;@aWOsPtyth^bV?U)(aG%b4hQ>z`fe%35gAg zjk|n*m8wxbKy>s@d1)SZN3KO@!L+4o9O{icR62fj|889DRR{Be@bC0l)t|)FCgZz9^ReE zHkw7mP{u=V^VB8HRPQ$HM>tq+sp*!rC)8ns!PG5 ztYwPa;_%ArRNVfp_qqIWXKXq@%Sa*J%d@0yscnYUtj#UsS513ra9TN38qkP$qvY!y+?1dxn z2Yh*JPhHb}os(B(dAqEnoV-7Cqun7de8yFW5TdnGQ_khzd_fj$#y!A5*-g5DM!y0V z@Zyr_MG5C*OY@`!k}>WrG$~a|W7@)_)L7&UVU6|Ui`}a_pyaOJ3FWE5_1s~N765JT1)(FA)JUC$B3v1OrH6{h_~D4V zEBF@*j@ng*_StiWyJmw~M&1E-FDAJyl2m+l#LAkQX4(C#nL2L08i%=xVo zDZ6IZRoS1hev^|qWR8Bqv(k+2XPZ1xQ@M^GLB~T4zr4)T1A?a7SJi5j58f0TI34YD z27**x6m1A@1>-*84c^J#k_qng%H{v;^Di}PdSB+Efge>^_VN<+bIG5cD{GA9Nygfi z4NGS#ELC6ku1e6E2#9X}fc6DoN_^zbWYR5-{Sf@@=TYMHq@w-)SzFBGFf|XZP5XHc zyvl?8>lAkW&%79w9*j?AYlWro0Y)RdtD|qzHR}^RPwgkU>E_EeI=oOS!YR{B9v`f}6U+XckIy6m-|igvTrrLPgZC$v!) zeGgZkT$Ddpr+=*MpSX}blr3{T!cl~aaJ}Xp6^BhC9rU<6SuJ|zzzQoKP$f$sYvZ|l!DT|7D;167zbSj?06>-v*Ew3un1~m+F zXvTbJ0ISK@!p0B5&eI5|30=%RdxvQ(jj!ky-GdClu1KQBC8JpDr`ZZy@b7GERu=9EvOA65}4Rmm5^I!Tm2=+_ni;gyQOB3_fMX=Y97Myq*CM3OXiLn?mXqd+$Wb zVmReC@?Xys{925M_LeM}UnJpm^0SnCR$=E_#docw>o;ni)h3y^Tz~2sDb~#ApaQ}S z{MHWOTzGe^kxiCbx$+sXg|-Bj3?^uMbxY@|K1Waz1g|PnInQ5S<@U8YmyHK#k5Y#D}GU{Cd7O#F>_=FnQwM@e^6R{mXA# zYTm;HJl>>*qY{o*r1iREJpCNdiu-+aBI_^G=j_$aF_w7hH;nO*2qZ&KxC*UGdW*qW z1sbquWmqIpDnG&ns&pQv7?Jq8(`dhb#55*a&Qy*dPxL#*jXeKL1sk^(TSVBUW@uCYM4ff zUI7vaVg}c!Lu_!JSQ87xSk4F2V`*HNg!Q&tz6|ep2BL=v$Sh~IBDA)y7i*nVa5NYS z7uN($Ymd$$Br+ z;*33#^wbm}JIM%$|2X#tIMtaVB$I6a(mqL!b-0iJ=Tq^_IUE|l-xp@9rq(f<916)-l zDg^1~HI#5(ofO*;w4}C^sXYFm&@RlQ;bU*63CO05Bp1ff^LxaDxZ`u~1kZiXzaT7? zDj{_;P;;*C13xtBLWGCC)yZMnh$sB>kPY5$TtxyJ{_Wj(*_2qa6e137r(b@`kk+>6 zV_vY> zpAT7zijSFaUUcjyGHj_F)bD(IpC%AMlnLOm%^AIR>$625iwpM@E$Jo7a1>stGU&Ow zDY}cO1PWgrjQEoadEiHxxR*I#jS$njZFm!fs8MwYRTF0+oRc2pL0FA1nSxt>Hlknf zqb#`=(Sbv;)P4bqXu$ai9`qE7y@_Y0%?&euXVb!i)jP3$aQ6=o={-gR&~M30{)yt^ zXeM2S6t=VoGZ7<Mb2kx7q$IZfhw1gXS z{$BcW>rBo#oRgvSH^ebl@Ds05q@`ffVpFa`wEwTl;=S(Rtc+rB-BM(o8!yNC)0VLb zqxPW$%5p~wB3MbRamhpHagjWUwG!2DI}=77O8xW`@RA=0C-aYRo~_5yjUMZ;Rz{8Pf zx&5n_0eKIeQ63Ee3vD^#0y~xPLx}Q<@M@|! zsf9k)bKiP4d(A-SU0K0^B*4KEF5INP5J!^~G}`nTlc-VW-Ss4MCi9aPSmcM&Qt6&A z306sw2OJlosavxe#|ceXJ)x%fI?yL+RnH_ zt>=M_rSJ}FS$PQDPx%7pSHQ1wC7Xxe9|8%Ge2REyesBp8I2y*MU+eVjc;g@Q{28kK z50qV^o#y=MYDyTk0U{z9Lll0>iSV~!IL^Ia-rCaCblrZzV^o)L76BjQ3a9>=P_t$=r@+mcC#)zN*Va%s@LK9 zT{&0i#@vfzH4hVGa`|-s=-Rn2qsVHc5Pp(Rd%d-$l;(FImMu7|^tEt)BG5e$tPxGJ z8IHrx$khkosQ5Yz$9CBagqN{oT9sGTLfJH!!phIOA1XYJzf$Bhc$F#axs;K}bsl*; zNxv}_CXx#PClTCc!k#bQZ{@$Jxrc}1Xw(sM+%F!_DWzg|yqXP-AZgrZCY=r!y8yn# z#t$#VJi^e>EUz2#zf)Dl^FC+`6jC*q=@+x4AH0Icj^l#fSeX760@STR-1|~Iz~@aI z>V94m7&zxZ-!UK!Cr!v-l2umr!ww;g2}fFsFP}r;8+I|dUM1}!dm7XE3BQT!Qtu-V zBm?4;tO!{oG8>kLkxB$wP>NQV*U-!}m$b}pSx?*Z-iEEkz^B>8IQ*zt&XK`|5FdBT z%XwTf8v*($(_}>HU3#kEa?)Gw*gaP8Te!mVFxK+n7HEY5me4NBu3bveuUuSI#Ss9rO*Ay1JwR1f9)Y^_*~na69FX&3G(+6- zVQFe+X2?rbk9Qg-Vs8%T2b-YM#2-dt?)s#8Eg%;(ai!UOUNHW>1fb7G{f0kfhw5DR z)7?kb(os{#LAr=4xTQ65)Myum9HvjBt@rnB@+hwKsx0h zsNX=&471e+vm2(lDJn4boI8`vZO*24ejU79 zm+}d`KXN;q87XUr{>tpnMAXtRKoe3$5{lGdlmDXs=Cg92dnzW%V*-U>6@2Y^?o2sI z-KJdBeK&QK030ef({x+}4wDE5O=eOZ&3~U>G#z6BS@`!Xt_VdfSD0jsDX=8wRzgo9 zz+_WL+4JH*5pAv9iC$+;p0>Svn4TiW=D2K3r^b+|upBI@KV(ERD|8B&F#ke<_D|c6?wM=>=u;6G%hAD}E zQrB-cTQlG1*m!ePZ=$)rMhBxo9LW*;*fr)5IFI=e9E4U$^xEhBI(f2u zy+E)=3x^)Y@!*?QN318B67 zZaCf#b=;OP66%yc)}W6N-HZvXSAx0_vY4MVb0yZHqsAtP`0FQM>OXc*elwDYeur&3 zeV^R{S~EZ#7=Wby1R#(yK?|Zm^AVOSZ#HYiy9efP=(Rc?S$BM%q=oM5*P*kA z4gNVT4woDuscsb#g7R#d}w`}2Oof1Q#U3zp7=kY^cH%t z_kzw$m(wSFP2BONdx)NrhI2YSuIQPxN~&JQ(-^h%&zx3I2g?TWHYy_v=Evco#T%pV zM2ki%j3L=q&w6iPu%m9o{r6OU!_WV^2W-UL+wNi>`qb+~NQ08_HKB!|ihu?bx1jaW z#>xzdDPdoQ(#Z>G1s)a?{;ayg6b(C2S8Tbl)AvjuI#2Fs>puB=SG~Rjg#%yz<+wz`H*JovHhl z6Fgm|$FtX&4*-`FFbc!lSwWZ#{lv$u1aZa1daIl2)76er`Fqq0PU)_}?x#y7wsYQP zU8m({6=R95zN;)_2B1IXWzJ$4zk zIOQs5^QqjylF7!A6w6F92DxPSfd*R2td2b6`;Ybn8(8$sJ9e7jwyF1?A*EeB{oz{>Z$d7uT8ZeV#$NFH4KQf z9yEeeQmq7G!++dt-9?UtCcH2y4EP7AhrK4oEu9YrP%Gz#1R6`Ong^RiG&I6y!1B_H2xo-@}o?-)d0S+@MMGhCI8&c6JbDZS^%7ei{ z=atAN`@1Jo;(*dHM@WoPcZ;5Kqu*>>lPzhD9(8)L8b3u|ukYPZU~ySAHuLztQZ=A- z|2HNG!j{&--d#xJqm@+R5Ikm0-1odZtESD_{i4bnt35Gj5tyPB%s;nw=8h8 zha0KkBo=SPiVWk(4>skU*?sm2?2YgcuZBw6M4q6O8d~*-8Z%Rn#VYo$D5<^4OdR%= z4ORfsQJZBwDD&u@1iW(VYL|Tm9+ST2lE?nGb*A4CS{JRGhQhxidLzshc4~E(`v4pu<7EY)BF{5(^ z+A+U4*)3qk%)~>gRo`1A5TS-=V(JA3>NWmMV_Rs1ecfTO6B(z-4{{+4UOy%A&7X8J zP(Xg{luVwu0E|H>Y5BRZsy?h#Gi7ud8MrC^F1PS1qb}}f5Z=l0V$%m8XPz}gkQUlB zkacK@2S3YpzBW57=6rHMq8XrP9wtGvDE&A^1osXlm-`wFhaH6i3A|7o!on#6SssWZ;HtlI9LWa{^~3i z!<(W+3arDNLLT>GEDxDjjzC+Wk7;I##3FKMilyeP!_K8?m1yMCk3L5%ml#4SLFs?M zMuO(J|DC~MR0T^xCYt=#l<%+xEuFM2mh6F$ADQ#2y4wmJ;Z!sqZv(zGu(p8@EC z&ITnSq8zw3r{DIBAvt${nOOH1?F@Wq+IET32By83d~yxgRV)wB;{}Z~r|CkOg7f*a z`;Vi2`;n`%Zz3=c;t@PcIMvKa<>Pq9QH+oR9`ndZP(l)!mX{fCmes7Z4gTng9yi z`;x;cb%S0bPjGZ2Z6~}GAnP3g*7q)Ry%B+KU#Nb zuV7}a;nt6A%$)!izr8$KV^ECp!fc&qdFH~vJUP3~6Zl+$hFkLTk})(weM;jyUd)#4xju@0;jQ7tmXb73!aLrwhtHl6e(Udop z$n?l!_xmXrfsI~I3tXa1__$xkAGyy@{cg79{BY6)TfCy9-`~gO@;d_Epaqj{66Ml&i@&w<*ZgVeUA(Nxe>qV%UHF zIZ=hm7e;Rdx|!Qh;6!Ndsq2vW+JQ{KMZ&Y3O2&*t8aRR+Sae{J8K7uuV4S{bmiy~A zxe_x8A8iZ&cL`w2fIRTE-x&s)VepiT-Wt`T3z7GIFr7z%FfRp^i*k?{QqyRK$EENZf93q(au^a4} z{LAIe+RggryZ#SX5Wh2qZylD?Vz5MoHK~oL&x8nOgk;0njIRFe{KZX}EBka!b=8a1 z?k>cdNKp7ZaTFrlDPQcgXa>mb9?y^rhQD|7k3fr-EdSuAO2+RrF3&bIaL-t8*YCVB zQG4VlZd1JyAB1O7U{c1VDgFpJv^<#~ytm@QS6(9u`LHGSV~=R7cQ;Gp8LVIO8N)aF zW!;bnSGLDKQ&c-axoMhYa3Yc=iFt5s{V|Mkm1yo_aS*jPehBbVt|U!*bd&dfiqWE# z{w(t*M^F6FX7DB8@mhfYkfx)Y>ps{4fpKW>{3at`XKQI*D4+j>_(wfR1<)irTp}b< z-5RmJYAa77-CB1QAr2-Sh~$5YZ_x*gy-vwhx>?seaf%Hhb zJy}LX3Ea??TSY)8*&jKAU|4lC6bd~H+M!?r* z$Hh&Z@p?-nfAJkswt5*}B=`36k6T6^plWh!NLiRh($rq1{4#S$YXUCi(6+`9(Gi61 z){ARpH7-m&dx009n#Sd*ZK%TW--6fR&ndymIYC>AOBfyR${e&#n4ie<~c|_@GD1WyZ$q z88^TejI4*NoQ@*0m9$Dt7cq`8h~-L-K+Ppx&8dvlz%YTQp3)S%IP|4nH^Zz;oas<5 z%?S~H##WM(v;>YII9)dT0ZyzzsqD5r19*u>+ z(kl`35Jw~t_ebg5yl_t37-jIAhPgDe_OuVDugVt zqV-L;lhSA65I^M~&JGaW(LR~{O#5F-4HEHT0+ht|Zt|$!n!np8F zx>6EBa^9Gi0ya=N@tjbsjw+N0&tkRBt(YB(WzgcH&s(3D7Xw%4dpm_(h)bK~Rte1X z`T)n!8|+#58-_A}_aF}BP4QU#J{s4)7f7|i57aytbul6IVPwOd+6JDSC~ z{e0E0Bc=yT$)<}CMxbO-Nr=7VoS_Ah`on(yOMd6gVW8D(_x+T46@<<{qaUUUVOhoQ^|(U;g5~n=D{ocS1+LJLQNkQsEaonrxaXs(pp*Ow zhu0m#fP>;@OI%7PK%FUKah{c0%n+-kS(P3n&*uT4F?XQv;gk?dtr4K@Gr$#{_yl#s zmlK-yHcn>$$WipF*NJ_@x#Y49jzc&8mTvog<^&JQ@2hL}-z$4vucta6WvLl9aPBHb zjNjPr70MCEn(n@u6y^?mdBQW2)84VPUv73Bu!yG=XTRE|Ly1-wj0N~#Ps?$8pPPy@ zMg^Zgo_453V30E_rL!ifi9JXUCQ#OdyI>1vj87gf;uZy|e%a3FnqD5*{1HRG!NL6^ zTmSO>he{B1L8*9EP9Wp_a!kx-eycqjs{Vnx9Sot& zgbT?;{LYQFDud^5VCsG{P`0QlUkHL`JLIMmm@yTN!ttoqIB0Y`%zOU zsd646xXTQW7yu@yB@P0SEE|7r!56+JMo3ajG+k=!_@Gk2*#|O2hT&_NGB$sY!j=Se zs(^$L>M(RW5KUXH9C!H46~DAKvL5{E&AIuHXvAoRP5R7Zf+4g-2A$_N8Bjk{ z{W8kCZM3A!L~N*o_;FRvDbz^v!LhL*fT$#j?C^qV_4Bx`mnXr8c!x4sxX)z|fTTqg zBVaM0nf`eAD|fENuSjehi@re!QQ}E%je2nYDC&T^^(>j+*J(q&@^({2Sy`xjGfXCPG7#pR@7v$rYp=C-?Dn>B zVy|)z&MS|5d~J)sUE6bhau$e3=3GWXx)O~$rS8x+)H(=o^E?_Oj^wY4 zd-xeB2ao*=b+S-=8|bnDA$1egyZlrFveWy0e9=cS_jQvLb|vu`k#0e8(fb# zUMtD|0wB#NKH;_#(E|m}yYoLMElsl&zOy@jh@bn@o6Jr5o`@9?l;aa@E}JwW+MQd#keHSqAPHpZnG&y4a@fzTYEi z^XldhmR|g*(#koS3`y^c%)1D0J^b$9{tw3bE(i8EhW*^qVwkrOg72gQ;CK(@mgp~i zwGTcRk#>VTyVQt#k+^0&CJ7|SYfG+cZ4%t8OCyn9q$7yu@ZL86x+cufW3AhHC11;# zMN{~?&xh)dR`)ek)xupq8^Sb($opeCK%q#W?U~Gi<_vJ5VHA>jLgX8B-12(hQk1Qq zCdzLWM`6Z(`YSb-!>a!YY0vhwxX`|sJ|F!tH0~|MI7_706up7y2a8Pi)p-9+$j#N~ z!&jA1aL_Vc)^7&=ShN1gSZgAaV_wtBfV{h%?Lh~2x;1@q46GPJ@+tG_OR~OHVUx>B zvqK`hwli%jXA5Js(fw`RasJW9kPBXH?Dm60&0%_8TuV*%N&aW7$U57&A_&2pW=qnmi&(aFSdGx_ zE{RH&yNM>x2!50G!2G;T-VZ+Ntf=q=zzTzAhv)oO!;v0ytcxukyBga|;ddXp&DLx^ ztkJVx|G}%iM&!12BNCX-G{{Xt)}o#hR0z!waxP1`$w$%pb z6FqQc^~3Ft`1;}_#b+&D+$=e}&}gn)gJNv7 z{Z1`blns4r;XyBb;m-BGtfYQMTZ2rKh-m)~ZmNn4GB7wkMT@GPN!{@U)Et$%SLEi@ z(?x3i!Z*NvQq3F&6vIgS}>wh;vB(m#XoRAC+-FfJ!=an8$7;hpG< zU*DDV<-<_%M!qyhBC$a$t~^wb2+T_ha@g zi@g}E)|dJj_c@_6<1cqaKa(>ydtX(T{P9z{UANR=)00iqILaR~ka>HED?>;uLt)?H zZoFgHTzWDmF1r^q1sR)6*I*JS<0X2oakJ&qdpe*}(4y6wac4|iH%*;;fDl*JRl4K> z;$hFM^+O!%Cuv-rv06NW=t)$zs&Y>ECCPdKkQ?S7h-VK`VAqAk*$6lERsTCH3@N?y zXnFrWHXFiSV=bQ(6(=&ERxM_o5dWjlpjU6iuRM{N~+0 zUvo4ueIm}?kz{s_0No%eo?(^orpQcW`cLEJ|MfQCY>Mq-kgzq;vQ*K9ureR@HgAUYThSpL6qP{AAIP7}n|X zdp956uU=y84}hFegicT$Nety7c(SQiL%nhJSL4^{1eM(rc33|R+~5yATMhT$*+52! zKQT0t<|Fq8_#q=d@xp#zGKU6k1lq0C$3)ZfYmBK!{^|(|Vwi*QXlQqkyDJZS6YKee zLk5I?-hH~tyvmL}*K&06tBpGvrSX-T=Wtr}%PYScx1V%FWxr$x z=BLXvH-T_8^~7pgp2`YA|70Iwb@9Ve*V=C(tpz!qhEZKaKadOnx&Hy9H=OGW`T7PL>bqT;P5zjCy- zEZR-`^XGopx|hL9%cw58??H9G)bs}P4$d%rnpbFSaPJekb3YhN_Qq=CI4_K2;Yt`4 zUJiIP%#1?HXYBWoH0$vkgI|95ZPtWv6MMDZ?Zf1K$UbFxQ zE@5xPSW7|mi4wPcJ}CI4wMwt`D>i_crD$OAu*z^@eQ$ZYB}=hGvA5Yl?ZL0K`XQ?_ zHcJmp{h*q66dQT4q3J)QDoT;FA6l(}-j~(Dw*V$wh`lV;#Y?X5(OFO{En4c)Uq}Ld z-soKQcbV$HBT1wK7h0+h*S+0CLQmw(ue0Z!EN^>jN1L%kBiSRS6a#HGiiJfWCGmAu zhwr4>M7Bqa4m*nd_gOp!mklAna{iTICM z=!7V9t?`7Cva?@%l6CfT?il(B)q2qCl_bndSx4LMFM`?E)7;qcvWV$&`nue;R@-Lm zRCc+OJMNpKNa=+WTeDAYyG!5>bsq1-ShA<~+e>K~X6E>MBUmhvOFzuAPDe0>4aZOD z>#A$2*;T{~0vdanUaUZ*Cyty46JKawOSuTnVvE%#f9Ml;d^wq~G87&HM9|ZZBJ4f4 zV0?om8iV&`(l@xFMFX!5o^{>qj|4b?s1M&Q6tR3f8Mow!v>jRSJoj;rGx4@l?*PTA zl{?h-#=BmWFb2QOxR=sgLp{bC{cfY-?#=5FgAxgRz~x@cUkz9wf~F`G_dE>;W#NO(Qt8Kl4OSo4_Te zwkB$ZwdA4zfr$;#Yffy({nwDyd;14pBW53(uqz?_-K?(*+inw^%;2dVMmm0Dpc~MV zO0lS>IQqpe2coZeUT_I5?)Ln@LRAPt?Uq`F@QTkX$SPjMvx`|n=VXA%E0Q<}aKAXE zF^I%QyO}qlO&4gB`*=+hcI9rE5a&}!m(U=k{0_al! z=15#;u@XiQ9fT27f0m+Zx%p+5L%aNs)^e+RNyB4X%boGOdWU(wgZREysms;dT!XP5 z!$ottRR)6Fjkc^r-fAwiRX@A_+71QY$lkKH=564)t=70cc&`JnMmhL%Zso@Bxh7#h zo8RO_o{t|%9qk(~R{ZF#%139QnZ{jiQtmZMIL!ECh&>+Oi8f~Y9HC)%3i`J^nXy&V)!ufthtcS)-dqO(={Me?%6 zq%ZEV{s<~y`=)650&qII8K-3!!AgR?2j<446X>;zM-(_V*eXA{75ark%#7khtzWz) z;u(6z&RyxXj2g6+SZ)yw=v0Z26bVD_1GPk4I2NxQTFKgAjS~N}Q9kQJRv#|^?DdvD z|DD^TmrTr21K)Q9k_b3~Qc~DakH>Xpy{097aE`REGe=eg22hJ-1@>uS?`FmvfPdMD zDA;VK9K`tSS|0tJ*AUTSzBrroDBWrANf$vTwc(A-ulY|F1OSwCD*GZa0@4G;BIvPJ{tM^-F=DR!}cj` z%}|O+H%L>&>JJh0wLp^ao78}?^DK__fN|#s`|qm4Pq2j-OB0WE^E51NOW3w(UtQo^ zs9w;qb)`BCrjAE^qBTrX-a$*ND5-|@JS zKB7t+Vi-Syr}MnJ{8|FZ4zn6E)DJYC-_kyafkVq-T~!EIQ-1qiWT&H+4vt&2FTWN` zs?vcUDdJFpLxh-5^iqHR^kL=)JWJ)#)2)#st**imn-@SL#_*kjah@msVUP2=W9#X| ztZ3Q^1ec&EkdVbRzcULY=<(rZ&XO*pL59DCuXANQZ3 z@FSi;$n7B`CN*m=7STBDdQz9O?hls$Q{j@AS(Sy}IZyQ3hIzdJ4v_u?NgIa*=p&Rcc`_dxIC%^>cDmih#O zqZ19@(vlHhc+r1;h4&Ih$wUx+q*B!xNdR9*Z6nKUH3XdQcTh9w>e!E)9+u}byMFjxi(Ezysd5*VZYv2 z(atVu0mw_nU!xn=s^}YIe%4Z8yUWYm<)-g81XB3ucuQ2^%6&{Zg|7cxnwjSf98@-Uw;Jr)9T4xs~8(!ci~f34{3KT+~T*m3J=kL zYBFnP#2Qjc^pqXjq465>aqR{#=I&Io@pB;bnReONI|hI-p{v3q6Ff@U%Pv~hi11o( zXu%l(uNA6ELvC+1mVLRwz-g?xM>FY5uPy4EazwT;j z;AXP!lTN9tKa@vior`sd9swfRxfD>)b4fo${ORV36BbAE1waSdW5EPqB>@o+f1CED=|{~X$7u%;^8#JYjP0_Uf!Q$`!*1t@4j5o@RR1WbjR0L!UXD?;cret-)Kg# z*4wNE9x-M2dm=Zn`(Ohgw>Hwq}h!A~lLi(@SBs*ZC`+%x?I_zq=g#{@ZCS z@#JZ?sx7GJk|g59NRBB3aEjVHG!Nyh=kO(kSQC=Ol2d#Nd{~!{nSfI6k4ipSR$j<0nTE@a%9X8_ROy5eQttNd+ zXk_i)Wr>xAO)t+)97xMG8=3#s>K#yu;HABe@#iI>4dSKx1j(u>%k8CTk+1KTkJ??P z;Su%wQ|mlq&-dD?2bue+JMc}T-RsGIjzz0*7c4apOzdmGO}JyanBBI z`~vQS^1s4z#rJ?uo#XadDAR85%g3=li)(+B&v@2)Ryx1Oc~T%Kp*XB3mCekS;XHow zkSkc=S8FX$R&KXz#*95t62X1pLyEf^MtA}LfVhWaa}s@&-#hqu1sR`BzbZ-4)yEzn zy6~L45SLhi5AW?KQ#yiRcH9uc`Ew4XR^FbU4(`ghW)vSUsc}Iy#>zk?X{d07?8jlSEinY#C04((#7hSU?M~Iy zXjSQGpO(t@N7(|ng$^Z)?jSp3T>Nc9GNWNlkovaYeqG-mEPA6)hl zev(FZRyMFear{`^Yq+Jeg#u|3f{X8Q0>YkB`>uasZ`U?jOyje>AL$jt1$vD6v~qQ} zJ+w1Xuz9h#!QKtIT#w>daML)+wMA>Z0EnOPOfQUIFkyq{8izqzMLn%fwSlVQ?{F9Q zx!dGJHE&aF2q#2Y=_)j;+NRYfvSJ$O)D1fF$>^a#PU%4)8_DGO`K0orL+O?tQDCv^ zmBE*Jr&)QuCvJy-MTb32fDj3kkS@CNBEfN1if7QhbS$QhEUPVDyF;SZIcLy8uut2o z$dO4*Ff-9*oh9_LCr+}6|BxBJ^87rA8J;OQ5d6FT_2(ip9P7VXyXPk z6H}Kmab_ zk`Hey^{Pi8OPiq2>pjk!@@ErVd>=NB(!X&;-t7#2xl0tGtmH*Ok(5We6h`y|7rfal zj&S$v0IZJ>m-M+ptMx0YWn4&mBo9YoV}z&xxfULHcgFf-d4qy=TbJ_}z$J5cn6qEY zY2jGv=$vdUxZlBSU#lCBqk*B31nzG>ZOu58n}>vtadf6I`uw?r3G~<*mz5>3jLbOL zt_20h8+RJUWmg;juDVl6)Mgq?cI@w3FX&4{7~weV+=191i;FjI*tYg>Cr0h2ch?uD-pC}gnn^5c zU)^`geYdNBNgM&dgXH$mPCemi*w?>ivQ}IMZ?jOOGd~9GKp=`wwM6ZA#PhFbXey8` zu&fVwff#_M!u7L9MDSAyJ>@U={%Ei{TIhN8YX}WzfHhj@z}=NhO|Q%K7$mEN{Ht3D z5L;<8bL4q%o=mg&lZO7s91i(`I}knO?N}pcE;#D4v+^cQcnm5(oiE5ox(fbQxxGoO!};I}RDlgD~7eTK6!XE{2@EJxV1&=}?%*o!FR%QXsEm5iQp z)b7&m*yt4@Gxt1yA&nvsq#}u1(mGFJ@0Twkb-wYfg zDibxLEjgFew81BG5vFwieUt+!u8}Ps6ACj%JSLxYBCrqtJpMy@icO=yiqv;KE>810 z#$B~SnGVO}**))%yzPth?Y7A@6)kduvz6v)zK$j$4&>m6fqjEW#!18(3 z@B0f^aiZaQVTZZgeEJ-XgwTxwIAHax3V1_Udnp>lGKlL$kkS{!m%J7oPsf6>{P{M2 zSvy{MuTqn4ep7>9JHWtaCw+`5vsH%nb-bSxsSPcT4&pjU^L)xQiZb)PSA6^QA$z(> z?ppwEJG9I}$N8wq<+NKrE(OXYbZHlGip0;9Rlo}sI@UM-bFbTXu~(=bpPE}^_{&{l zvs)(3%q$N-N%nu-vK~pI8TG34h0o#Y?!U6?<21gEf@i*Tir)D3Ilcs_;8Ed$%^`g? zLzo=J)XI3(FDk1pUejSs~Z$4#{1=g4q!-)Ic)|U zSJ~qH*!l8S6pzSt=f5$ItJKB7DuV#3eY+2GL^t5~I7KBDkNV&)A)D5M{^0~S$rv0t z^GVR(&E`70@P=iomN!wf%R$i~;-mwIS0FUU1A-w7HZqeMY!ufB5dWDDa{xwk#9mB0i?-PTg&TydwYbx*QNap z@pa%~)tKXdqhl`jE-}IhF_hD*Y9Cx?Sz76pv6#dxJ8|#BB)82aDQ4?!Rs-3b$%c@; zp`SFBQ{xmaWNEy-PHy&N;+E?v<{lJpE~T;3{zx1~`PPfT>o#e3=$g=Nn;I=*`BYTNB(9glkU(OR*AGgRv{ z9O@y9V{MuMGfq6?lLb;b1sk}d0%vQ?;sH-ycPQO>Zrb0{EohjlJ?XYZToC874?!~7de=InF2T+EeR$g+lr>{78 zpqr&fV9Pwa)AEA`c?*nOEa3c_&ZF|}0(0>KBVYbPTb*+i@2Q_vPM~@%TlYj`fbdJ<`;J=HG2!YFAeE zou?NM2D7&o4(ptjidA96<6nz)4TakpxEnj(cKXZ?PSKT4o0=mls`-Ua4LkP8MIFK? z?Dd1=W-~P5Xd=!O9{#DT^}@4D<>*Um=@XaN77^^ZS9t|5A%bX49g@;W(($fR?0*2)8BepOEF;d0`K zm9c3|BkB-v>a4=0j2cGPpU%R>&aDFJ-nV1~jk)M*j*VXJkuLe`tr>Y!#g%37B_ zNW`cNX7Q+A=&c4kGq`b)2=G~ziMqi4l`0d8nra(ITSQHZ0Jd^RByd2d)x2WFxibuA zcWT>ZV@FgB=~k26kU@Cbk3;9SYpfcqM!xmN(1XF=#X0_mqODh38Ch2$w+Y42V#NHZ z#_YN~!Xg#PCX8eTL|2h$U#JQb`6=<(ovtO{q>F}bHAo3)%t$K`{h4Z8d2fRC? z^?6efcA%^behYRw$?&wc=m2|(s8)b>vyyIi#Liahv)(rG>($aDF5zhw<;r~+4Dw&2 zg^yrnN~lrA%?8i|!69KS`EBKmzV!Ga0##hWj>A_lhDY`5`JFh(i;SA|c%57+k9nXW zQpGfsYg(a-zpm^g&zIg!^_7IGJP`L}&IuLw)=)8ATajx*UeL!)Fp87(WM%-ZcjO~5YG*Tb4 z+7%5dN#{rii7k-Lhk}CC5HUy@RZiug<59zW&{0Ubj+)O#^0sQ~i*di3`g|RVAt!?o zzKRiyAnowWia_f|(9_Q1C+F!ibqecf2ZilS=xnh0a{IbC{5E4((8UhvErCzFK@8zy zir}0|BeWh^3NT~z2vVHPV6rJyhm7{v`fc3q$a|YxBZ_$}y1VE+xqRFWt1+;ncrX3( zVQE=i5RFISdDpOuf!8Q|CYFDxSL_H0qkYz3twM|n)CsPJ!@1^0lo4!1cKOl;p&h-A z8fepsI7c1N6jRmP9d^`}{Z?g+y{j%ynEWovx^MW1qgw3CZG@`G~Av;d02PutO1Lq zwOnzYh<8{d78e2A-O|3fD8kVp1@?Iip`r)%B|X?ETq9v8MWY%&w-BHa zzUAt%1MflU8+4|Thii$)qGh8@;IuNVW{N(0#m!LK_c}P|TPNnQDKsuFHKWLmLLb&L z1DUrShdQCWnL3mknhl!Z1{+7t-QR>^exGOhZW7^s=R%9yE%Y34STVvX2#q}8$FU~$ z^Q!BHOV)I#r|W5_Cngg2-vplKX?vn-j?~vS=VDlHqGCS=NvB#azkaDTMK2b7jTIk7 zxA>h6y#<>v1Cn9#s~j+(uE6X;Epw6gQ@F6p?vcry-~F~c6>g;9Sac3{M0jaT{)f0f zdITzQw7fg=u(7)}h!hT=U_i}mbRaKe#$d4x?wu%PU|_I%Mugp>9?bt7ev=n`{kF|` zVybm5Kj`KrXx+zpjtoBQF`b zlBDIVQ-fKa-uAL}2$Wn9X2DLioxBnemh~hU!o$^nF#gjyhy=r-=tiY_df*gNjycTa zf2ljB+XJy)@^9lH&<=J#cXK(G;C>G@okWpK?pY0~VxHya1Us_U*>~`su)fce!#`t2 z@pN9w2|2GkDs$Q;{8udPN8*ywWn(eIE$@WB_kLTX(ig@4MLZVlYXrf-z)YQ>8;p0F zfbF+R!xl9T-Rc)38Nw-mv%;4M$%A4vp8e)f3OOxPGt#g#ptbC~{g~p1M>XxIU)wPk zi^W5HvsqtKxD44ei&gGPe#hc~0YF9r5)`O;Z%(RO=VrM>{Ght+qW=9DDq;@^&D>!G zMd90xZO3zU<|Dk*PJlBg8_`msG9~=5r-W1HV4-PTH!3PmYAi2itX;8Lt_`X>E1m~J z9XE<-+9Y5L7DCl9e19BV1*gySWBE9!U9Q|jCsGCyf$^YQD_3-DH2<{<+b6wbrX;&L*u`KnW(W@7R zr{HTyFovbz)u$#C2y`#2==^ZpbF`d&b5Qps`k+Csg3h!K_Kb8ktut-E=IJAw#S$#C zPA2MXlkWz4!Z{{w8ThW{(8@7zys*}@&$9!5&H-0lH_hV|{z{-N@YOh8GA`*w?*3Hp3LU!$yYDs?W0L+gTa zro|D~(+GRT+cPMfCzw?kaz_XO?4qzOlmtngmmuFy{*sNusaygmM@ zp~|B&%HEjfQR~dItz2p7Ig-C>D_8jao$cFi_DUIRr;E|hI_yZ{4R)c?tmCRBwtB2| zS4guYfPJbG8uSnjb4;$Z6Gid63~j~K&!U1HK{?T=I%u%a{wsgJsj&o9YaZj>EPnrp zbFe=^bWk9y4?3W$3+qSmKbsp1eGljJmzxh9Q8pT+$`vSMlrr4X;WP|gMz z@6aGsr+O(s?U7+i#PsnN?!R7Sn(;Lnar3dNLR}u7m8SB_&;9F?<}n3n(iYle)Aa1n z+7)nQc(M5e?{7OzaY2dY7l!5me#(u-2u$G`W1wmHq+j~Wbe&f-FUyt@1c}lr*`~_~ zjx*s8vykT_O&qFrHv8QdDgF83XPL-n=aMNrQs~!W-avM~QV!{%s?c6~q;sQIElf1M zvFA@DIv@K;%E5_mA0r%kmuc zs^#KblRYpD&bdKsAV%-ReQ5A?So=rvKa{L4$Sge)j8D<0G@Wee94uYFQ$Nd z(rD}Bd_7lFWP)+O25(;ZcIfZr3Y&aC%V?>`>KMST58iN!R!nekPFL0J0vy=%GnJc|31W5m~zj@a-?@4@{r zV2gSF!L(&6{Tb`qO{DNyrGvKf;qsy=YW#(#J(d?RQ?SXztMn!tM{M>!Oc^p}8*bKd zFBc=MjTF+C1d%S0`C%+4j?DaeaKvljhlb8pR}*Mw!fk~2Eiq#c>UavYd3ZmdL8yz1p~nr+1t(^CBPfw=J2CfBrG^suSdk8!wiV| zittx08JPz_1Cu2uIGZYHsWT9KM@pR2m~y$zld@`+zOua5UUTAWuR#;E`oX@*cJoxm zJ!6+H{KJ{-T>w;n3utAtEL)bnGVTb86L)7`0-XOMfURZ)_dje^?8ICcj%BB{P677S z4I@WK=qmDqssEjW{YQQA|9-$kh+ppcz!v?@{pTM_|Dw@B?6*|EUoWOs%>n%XHbHh~ zmYb>ED{F&WO3u$7MR5t%)mBy`+cTB8J^g&9DlQKyGv_95N6>a-D_ls2TFlDP~=+iyCo$DH6+j*C??3)!WQxEr#lc=CjwRB^Dr zN&5Dng0GBV3vo|vYP=?bM6kVi=a2%-xV0_g62ReTJ0M){r>ZL>^*o{EA8L?rhW>QUAJ;~Kf*n+cS&S>o%PKdM^1-o zP>+}iX}*2GP$vB?xKD#$C#tCiHdUdYJaev)WT3Bm`}lBgHADiTKr&-DEg(!Y^S=JD zVT8`zD~Kwo-U+a)YI6eSyFBC_KLXk7)O-v3uGrpe6O4VifZ3k)9NIk_+}*I85frTu z--2SkYT2fhX-?(V9=iLSIfyn|et%S--Qy@u2CBbF`NZcgg z*6~^9h8NvM$S88A^4dg3n!@OJc@PC~(8WNjjX61W^t|ip%5P1yY|wFoMYHYGsm&0r|PvAsKdnJts1v=|7K-|Jm06 zK2G9^aWjJI$;z94PnKitTs#-kI{plgyc#h@bjWatc^$THr(Z3ha$=ds(cfAHAK$pn z3{#@CHO=8LKGc~3X2KYU52e@`YuX{*?z=&7)}OFpdCEjsiCos@9y2(Rz&N}T=~{C6 zTxhmjiRX3Ra4Xn=`BmT_k?)o@2Qv0wcn|hFd6z)=!f%@lXtNfWX-;9oAR73e0d<_Jne{A_@9yQ7A(rK z3t2xZZWe|DLCPFH2nx}WiAUgcl`e=wf6dg9^E;PnOE&&La;G}Z-1(kK9ei8x73b-~ zP{B!MU+j#+v2Yk?V%go!&T$t4f+(Pjs2N@)92&P;17Eid zXF~UY+b%&jsFbVmTKmLORXc5l4)E-1SD5NKbCr*U?z5N$*xaJZ-}_8egrIpeoN#B%F*G*;7t=RVRu}e5 zjcFeHOI%ZdJ7h-?{_$3S&l;(g40<`s<^soHr(|z}&hPWf>sDwbqa1*ku2i!U*kEx1 zpJv?rJYYLh*4nH8BpRIMGt#@vy8Y?ie`0U{TEHRgoVDg5{d925@`A1Ki&@I61~+GA zsXE0s`wg@Dj({1s#t)aZk2#FO^sje=0dXIHU#tdl{?IFU;4C`1gr!MKlMa{2LfLey z5u|e5Vsv$Pa$^s^nUYi7f8l7D)j3)o+>B`7Qs9RG!$3J%>PGcbYS=5sgm*@cfhf z^BSe4@gsyTm{%ZMw?3Gku6<@C6QQUI(yY|u6$O-rKhIB^-*X~>*%Q;E2f%n>u_;ke z^=c#0tE=stO~70u`D9~CDmOK;Z(EPZLQ#e@ycH6ApOUg+%6Jald9h&c2JB}4X6T?v z3oHn1Jzv$b!T}^X5RctS`5@FTyYEJfumt=dZAt9Ep7Y-)UPwK@>ipCo^r;0nV>+?@ zd5YsNCWblawuMl1mH)ma*fpj>l}4E1I6Wrg?Z}rW|6U~j@ve^LE5k*sid= zkTwKV?nJZmDR_G%gWaH=_5DeyO=e_7(0OXzf&ijIVa2NBrt~`iu=dygWaRul&@Ref zq#rdqS?a2JHkVj$=ykC4e3Mfv9=OZpukCh_Q*qL@<@SnOfj2M6MLv5iwc8d*0I-Ru zhX7gFE!;JtKNgz{devI=vB~}!YdIu|?({jFK>p&w6N_r3VwyJZvtzl;=t{n$9j@zb z%=@D=$=((6Xn~K@Mn+_gSzxCEZF}}P5`%tFNr`?K4E7{8!o23RUh&<|;xqku>&BLs z5kCZ=cYMWa*b9?V>DFR-4NKG*D1SDmz}?;4>dUZ;^_Fkvfy~kW;S>?mD&@{HxWy97MoGpU`9J2i`qx7K#}OX#=;5Ws4)|MAQqrz@_8UnI!72h&GMK49 z#lQUd(B=512@e6?w_P(@SpIKY>!|QR);pqzButkw-|=I zY;r`$>7*e&9RCZo{?9L*qZDy{ZIP*cg8b?%VLoMQU&Pf@IDE)B;zqh|msd(BXy?-$ zg%}1D6XtD`&TrDi(b1JvT0{^tCDJt3o>I z-qgu;KE7%_H^EFeFnrDe_HW4kzaC76eh(1r2R7oQ*jf;~T%>eDfHGyuX(xU@sC(Ju z)zn@|9!S!wQYaKcYn1jx=Qk*g(yw4xYwsMHq`nGWk(v#I07gyU0g>%uPm@!;+u)g5 zFTHbn!mZ2#%H=P@umDY~kvGIi=(s{081t(LB|wXorA>#$0P?oY{3vd>@4P@+VG>fT znlb1;Cb_L&y0*eu{Ug+F>+!??c_1O}EH2CE(1Cs)g{>?cehpx8HhzrYTkaZG@islk zozaW4DtJ;EIBc%yYd+B&`NzGmdTgR#{w5b6D)LQN=;q?Lz0i$`;V8+!le7PL*WZFm zC={jADPMTzGWHy+-`eu%xrQc(6Gr8}W?ZUFHI=65cBZ?E*^{x}`OJwidv3#K6(Ogk z)RFA`27kK`X7gTFJoB1c-3Y?>;(~?DA7x(SQE_GRLFIO{=HqvO(mkFbB(vOz9-eI1@SzlENtmELJK6Z`KngE7FvTlHJG{jvZi+QM zO2_HBeATh@4;*8V_q{Z(i6XnUpo@14NlyAs+&-%zgc6@~07%XQV0!?eXXF&%qen^! zIg=exr#zAW+TTMi{es%aB}5B=!!$HBEP#TC&vpHEnvi4qgBN=5#XR@^JaRu=YNgew zJea9SxwE?~B2ZYl8&DN34rCUzl75X-DZ%*w$ma&6ePU78!>MMynl&bpyYmet028DO z^?8Ff-#Yxwb22FfJ2xEoXZR!cYr9vGrnb}`?`eDj$ z4oJm;84S^xKw7Q_X|jh>k>bNq@3|TxfHdTLx%N9qP*3ZW4sc2oX_aYM(adc=kac=_%lfySa)%`_Bk8B2UuHI;)-tgtn znjSc*$J)K@X38yRccBp`{-jofVjwh42x>~>;_uWV`JOVA!4x#lWaDI`FA7`N;}kYMUHUt_jX-@G#$AxN(Vv|ZyRE?2`Q^W}-p&MUPO5lw$*MgYOc+oCHJUl!~N zp+cl^0Ob4Pnj1iknNj0;;0#ouY(J{Wu%J$aeKjzG+e(@N0=Ato#)Ks_zxqrz@JA21m{vQnJ|0k(p`Y2ws z*nc}2cK4~?+JL55o_AMX(H$+gsEPmpHoP*l8tR?sIGBGFEOjKhcc3h)l580Y{3lS6aicr0Y^S z+u2S*++eACLFR8DAT_iD%)QZo(mPd5Z7yrw?=yU6ZRaM9yO8Pj)pra|peyQOGZ4&0 zM&BPmdv=yt(orR4Jp))3OAFw*fO$x*glxutW~(AA^RyXKz*l4J73Otl1ZeCYi$Xt3 zoWzHk3kmyFomY@UXtVWgdxfwX?BreNbD{9=lYr_fzqW5#VL%3@Hpk(-zhlm^&glB(QJPV5K@% zA8i#H$gqoS0RR$z-2H_Vof`%TAD3|_ObtI039-pm9LK4{{a1ACe}&7^iNG3OM%dSo zi=bRu&W;>_EY&x+vtgm;Mr0ygxuHnXnG~>TY$vx0EJcnpV@NHH%Q>;Kj=?j$L;g2Ly6#Y@SaT2pI67)<;_E=Pp_3%G3dtW`Ksz>&Z zr9x~-j4QBh84(|cnts4Kp7j-<$u+@%@#eFXV$+~nfyfG3Vecs!dxe9|6wr6TB;qp( zwVf$&#Mfn4H)`1AUd)-t4FFCRn|FQ?EU0m&+0mRGf{(}=7aI0~a+-t=HA71L;29Gj z)Q+$TpVD(QQ1<~;K-p_RR2$`Y9<%6sUsD3MP1f-)GDC`G&Dzx;7KhB}H&}^A)$>!8 zB)(*C?1pzBSKz*mgbUggU3UHG;tZ#|z@Jy)q_y9fsj=b=lt;3z=D%|av zq%)zN5w>CBK~0OmFr9%Pt{b7*8As#l8JNqBxT)}3?F& zuT6e3pke}!5Rw17^1pHC|GFk4MI}7?5Y4;QQtXBS>O4Z zI<287QYEulrFblz2N)YCfNw?iNQB8?^|zB))llPQv=hJk7Esezeg%d@NwCR5%D#ke z>gDlU0#$;A;_r&#)?LxPRss@Cc?@0IL(+dQxey1UOzw&F7`4;I>d!)BR8^<&YLvf1 z6eMkGX_pvZ5xzt``}{fZu&lkO>>zcsjSFSqT^_>)0i{a-Xc0B*y3hJX3GmKSc{bP4 zA-SCQicoWHD%)E+N6hoh{rhS#hBxTJQd`USRikic19ffA3_DZJu+qa~K&7UZNmKiw z3;>PWs=3eNMpm4FW6%vanHCz%q`|>yr2Zu@97<)atEwG@t8gpVeoaqn`6rZBr!%~& znr#Dy*pWXLLsc8mmj(Z$Url*mtak5*xjhd$(t= z$tgCLEorDzW$@5rBNU&Qz<3*U0w7S!TDc~5AHsu^`HyN|S3*Y_U(UR&hTt!#g>^YD zHYeLo75fT7?Ye;y|LnySEvaQmVPWJ&O_witH&tNiZ(nW9e!#?8`tzA*A;xa=m_6I4 zdxc9ehcW2VtQClul6}+CPnUrlE!%SV-wWUmDUKX49aW6?&A-m3{|h($=d}Vyddxsg+7A#&q^Z3UuF9uhXJO>QiGL2MKa*Uo2&t}N?ZSYdu#D4^4@Oq zoo3Vic;;+vz^y*HqPh*3SX0hwu-q&gMle|ek{0#aPi@GGU~3As6LCcBOaQLH)cnoW z#^6C9wo8C_UH4`FT%K%f0u6s~@$hR6v-who=8DLk#$P3MZ zvbn?0^D5`&it!4FSP~sJm&n9-QYSBng$gjXFO9hDrc2FCJ_C~skq*_E4rDe~_UT6P zj5~m&18#6NtfVc3b_T62zGquj;#F4rWWMkVRMVCd5U##U1)_dX4*>G)kvat2Bu*cw zrF;P>QgYFTbsdO+GYv1sqaTAR=yQUxS6gtUS8LchFlYD;9CYK>8GF6Bdo>4Al=QzK z@%W*__5ZN<)&Whn-~YHG1|otYDkX>l3L+}qsbYWu3>YN}jP4Ky45Unu?rz2y-7pZP zyGD%x(k-3icj42|=PC95$M28te}irJ?)$pVb$Fqkc%XM?0nlILtXp=Fi@cq9 zxcd0dTj(q8Ka3K&Fqi?;d-3thdP%Vc2ywm-i;v7eI zs%DYSl(&Box!CjKpYV*S&=uRy!2^#`)frZ*di%Q5_mvpRL2Gz$w?dT7a%QkBBh%=) z={ZR$Oz|O3YtGlNCX45a>g8YA0!ul3JlzR!3=JP&Hh6gLAyPFzeVyIefN`}+Xx@rf z6Yx$6FVC-H`1*A;o{z0s35!qA!+4Fl-VY zh#bWRIL#(XG?UTt2KUHNgKR}#tcz*!Nfe~CbSA6*K9A~F!RE|rhaD=d2mtN~f0=im ztO6@`xo0NRucFZOWG@kGINPR3KNi`Ojt(H3(+2pbV1*tpR(dxpaZQK1+*Xq+hQQgb zP)S2eIXVEynfN%pR1$p~l&xyd50tLPS~b&rECo|CBStr&ZP#Y^(AbrB64c0a`oeZ2 zA3m6Ii3h9p*(gcVb1o74(b#;je81&vPA|H+bh+&wO98UNz8GiKE_=gj;vt-Oa0yRX zx7)_j_YdayeM$2Z> zD9~?wh|Ztn7xlvEbr5X(mrvsze-noK%_8h4j%78Qim$mfy_9hQ`qE!OHGa;TDHkq1 z|2(r}DfBuM^49D4D_HWw)onFrNr{d~D-Qzv;BDq@<@r@4#h()?Y?BRXdm^Nao)VSY zQ1Bz01E#kbLA3aoeXugw&XhCH{b>^#4=X;hMSW}eVk$84qF+J^UWiFe_JHGJG~q&y zAj*>zVU`LnghO3J=T-sGiznUbo#ru1anUKBJlib1bRW)(%RWT8b6ycA!#-hfq#f5J zvZl6X)F3-uSkOle3mc(LPALri_+Z|GVj#qF-s8NO4gP9KjRj}35K>0hQ>QCkTo*%=!;b5-is1OHT&Q6SGV zXe+LLVg7Z+N`l&(tq3-ZfI#X2KfYSRoiK~V_gV}{;JQ98w9si@bv;D3*pQT%Shu~g zl%P}JUZG27Zot@$%Xyqy4Cuc)M8Zswm1>5!v^W?i_%=ZGVOs^L0g8FWe z`Wfot;bf6{r-3Y;H{k%2H^n~v^je@jlYfW-pD3H8Y3Re>}V z<>_?cG%YP#=586UPt?-yNo*7Ja3k5xqNG_my~80~u(|*xjY*^SXo&+b!4|jO-1&i9 zBEYya&y}zDz*KF;Nm~=|hxnSHAOSXfJAxgrdkA@8ZtX5pa|wPJL?O@2vWG*GAI7{7 z(`TM}BFj!%jNF*we{s;ub>#$VR4{Na=mIobob{2-o4@YWKOpqqpP$7!&)A zHLf&)Mo=ONZ7%kVyu` zU@m0D=P5UcmJJ_V46bq!orFsgHT=})=0*(jIb@DwX*!DixIEb|QlW|HRKq5A$(G91 zesCwny=jx(UF|cC95e-py50C0XA6bN-MgC8L4=?o19@YnxDUo zHcw7;nS9cC-0iSxlwUnhG7g08FDs>oTPmZDqO?|?1cXJ9h5WY6HP$XZns2$E6|8Pf zTR2`h_N_YsLWM47#777oaU;D|$M>B<`9do4^hP1+xgk-)kF-O=b0jghStE{bRM`(= zMe6~nbbq8(WM8h~`2DTQlGUSRkO7dc+>4pQeb%L3-1ed~?>ZQ2=IL<&#i^X}B>IyU zr;yY3^A=-}B%-lW*MPPrKf16t;TcvVWHVzncEOn6epzo4L{HlhQKF9b7x*q;z5`k* z#;#yr0aB_~E{4g>=YqD4>zdg^#JWM8V)VV(^w+03rlwkjLRzPx`CYFub7SbDX^k_D zlMh`!%h2)By^*0~6>_`+&5=3huppt|Uy!+Hh9Vi4`%E#$M7kjy*`XxOYgp<;yfXCQ(MPgWP624TvvkY;Z#0$cH13p&y?o=g)= za(J+i56t^xFt)xBpthUr>%GRHL}72bG`vgrw*8gJCUvebUSo^KSL^I#a0r2?y~NJ4 zc^LFGJ|Dwz-F$uO`Z!H$tTC|F{lztrB5>g)0iMtKEuX>2hmieqY{QUAfUb6_qe?TpwZJ;_B25a$&N~s38yWu+be*=~u z)AtSGyGwcB{#{V`x3l}_SA#j?!;^Osja<5541Ud9N%3%Dt4@j5!yFtX6E?y8=CM{_ zva}sY@q7x&3o?vu!=1CAI1jS{u0>OhoI>(L2?Aja0}t%s+MA{x4)VRLM&;wc&O8s) zgwN&z-{k(&r?GG8g}M|#x+~N${z=7)xz0yXU6zfti4}dVakqtrAY0w8hspC;8!fqy z?I?z?K!JI}3Zz3+I_pvir{86+duugsxfbt*#!A_6*%0C~;64i1gV^m*$Xav1)7C8E zW>ZPoY2ew6HcWq<>Cx}}BtmMbcdN2@?zF*7CsHX0Kzx+}hb37`6Px)U%63qz`EEiB zlz;}Ni#e8VV!TVgRMAF;9gFX~>_^JvcrHX(IPBLv<%WiMB}NlU4l)zJt@wX@mB|LO z^|m_P-N+bCvfWJv@)cpm&6@j$8aeO#)zhVQZBF1=13~GINP@TvQRI11!usc|r=O0F z$4JPomB{UaKwYHcUWF0N34~;0sczw9T`}IGo5`ox{KtI4!>>4QF8l0htWuS7eMET< zh{G(7_&qq(Z!=i#5=-SK1SZVW42Ow2+LzBVPaSJ-(~DTg;XQffM`1i)T-bRTeU z;khQN^?~>@G$$Fh1+4P(u^lcNHw3`uJ8v4N9l(&{ShQtvio4h;Q*`|+7=mhIZ>U;t zzA3{(%M_&8#&}P;GzRZTEIJG6Jc}u&LEFT|^y}s8)CbnyW^fz<{HTaU&LyMw757cT zZy%C+%Ja+Wzn?#NWOr=qUIWSBB?!w3 z5Uy&YFAZGH@uVvTiccJ8`tqCI6e&-hB(jb$30VcRPi^D{x;0WhgAj_#TWYRbdr8vP zeU@u;xCHahRjVvxcHJ3TksC72dY5|X?bGFxyhv!9qr^0U%gRbFx*iS2xbf6`(hFK8 z(}_BC&!9EHP*L6?1>5Kj+y;oqH(tlpTM0rvKzKP~d>dCyc6!RIOS!)~FNx=fQ#HNq zbD{#8?b5wK0H6Sh#i9X?SFFn*pu^}Z4uR3b03d*=M2FlyXZMj^%oB=p(dtRDy9tY__wmF!O#!`5Ei zfLf|q7Vp{r3skFt5z!D5ev8=s$CGx1k>U*zNHr-j#|_R;$GzEFl@UWy!RND5w?RQa z?t@5#lXng?;V!1vGf(;G?&-^qr9l$XpLY;LK<_U%>jl#_Mu4aK7^KQ!mSlwn84vpa z^fmZUu$OJh9*AstpHZxzp;&z~73Z+j7)zyq(;5On%ZJM+()2sE$eeCv%YxafIToqK zif9e>0Hn+K!M0ctoM^y5QG8o8q5=C`io5(4 zFwLjPcE@zvEj#L}LDdjLqujS9V54w1lg)$OmJ?uCxBzI^Mlb%sL;PEn^xGQ(SHu_i4lIP!lfK?M zVX#yzE3@|2Doih!uRpcah3Es6<9S6-0P31~jFsc~@{4mK_Ro(Og5cY+O0PNkL*(aX zBB{vgb4~FB*)t%X9|+3(YI5}3wL!sDBEpdXB5NZ(u&4l_%49$|T6jxu;l@K+ApfAK zPo4Yi*);v<8(2+50JvZ+=n-=@4onz@4)H&1-?suBh69jZ5#XRltG~F~UT$$q)-`^w z-T>Iu`lV(uP7oCg0Qn!zI9$V~-r2o~Q{*#4ij4@{0T9IFrhJ!4Ea-K$z3yZFfIZS^ zphTdXB^Dh4T*bSf2SNvokdesGR?^ecsGQrhrc49r_ofm80>l}fglXFsYV3+P?n2vB zKe_bAd5apPDu6gIG1NG4@;WFDV*wSyzJ9C^4&kn{vxDK7C(ZJY(+j?YoLtLKqZ129 zUsyAb1S;2RzYXdL4%DBm>1xT|UKIWy$VmGg>g9JZT)Y9K2+yPB1qD%wcPLfXzVMPu zHL9}PkGVGLO(-FkHp33tRg&!#t#z_tXx3Nq(^kjlS<%ggdbxXNR{t%S<9QAQqG6&f zZXW7@mrYj^4cKUJbV{yRqZMD~o8V~lg6LGbKq7Ik^;?gpyC)lVFWF4T>)54_5>IQd zu~x@olR@OP$q)t#Jr}DP5H*18C`4;P-L<5)w)U3dfhMpyeC-EGPy2&VZs>c{$B%vW zm`6w`S!#Tl?@6k;^lK(m=r~RoLYiTq&3tUU8Vn`9sJu_CHx5z{y2bB-fp4+_BOs?O zlaXPWQ|%i;SgN7Egaeki6?xDD|7P|H~`l(=zws4r)}lT{{?iqHQ)`D~b3hW#7N;Rv;! z`>sAv^_Af_oTtfDfGYo}(9~aSYg+yVco6m3!U{Jv;ZkG}kcw%Ed=~;5tE%j~+pAtP zSBBHw<4*U1V0XsCOdBd!8%dj?uT&W_J31^jmnSp1bv z`ES)@`4Nik#xYWf%0d^mrPaQSdt>JWLs0{>W@!4=T%q=jLQgN9Vni`U`FYpTlHHAF z#Kr5^TdAACz*m;NL@$@=gJm!b$)GDuzd3XqWWeBKArgQ4W8d(`V(7jKypcwx?oHB! z>GQrZYGk`p*SCkBMl_pb@Z-uY%_~5HUI&1;wp>Fc*eOuGz+1m(|B*`QKv4Ex3&L%j zpjF8-uk{shB&9$BhH!oGpYE(Cj0nCu;{y^2Laq!zMw+aWUzz2Tc6uTryxji~b@>+= z_}TD_%N+%anYc>99^6;5Ngb)d?T-v_o_$>!84waq0(r!L(h+8H=@EW=kGKHL*M+AN zKvG+LZ&p-vwVbSMK$+v#A@$~?f3#^XCCnLCwU71N^JMQIgH}ua=5zk%O=e_0c+zt5 z$;KCDs5R(UBiIB{ZFjbq*!03X>tC&YHg)kD$Fp!Y&G+WsOD~#YYn{Rjag*(cf{aZT zsGKn4h0*@$mV_CvZTo3uArPDRY)ktI!9(<KLvtW`;Ad$w3pt7buO4L z#kxyNVW0xt_)X((DxbR_2u{y*oCZql+H&;eL%_IzaDH>9!3^N^GXp9~KcGTulr$Zw z%xF(DQLqz%SSuA-%JhY9LqSMY9slpn#mj^HOD}iq)mpVD$p!%{II|a${kl9d9A)WC}61b1~0Y&Zc#`a2e>w;JMn214BgH zX2U(tfSQ_{R|`i@rhp7b3UP_eEHs4Q{ONrKL|tXtG5SBQ8>9Ep@b=^w>W!>V_ZBCp zuNnPu5B~V!zdTtk4~c4HAX#$pD?Qb89wGJZN`uxAC?#}5=|IuE;@Tlw*-;JlQ^K}V zRTY}MWxbfy9@9#>wU}XD`=znQ8%Et31p=CKr~bna{o6-|m7E7n4`B&Qgv*f}8Vu@c z8oNcoun_ny1KMx63KJe-P^qc=~Pye&;z_|nqdZdW=v_H3?u>ZIXF{%X_qh4H?G^;~^k&zYC#%Sgr0=0_2GoIgxlEew8 zqleM+n+81i<}HRLj+G|eQ^)i2yY7~5w%jcvJVcki+f_jxgz3U}2@T(FaQ^BJM`ZWE zz?4lV#thI#ab7?*3!KZyLLYMF>ia;p)4QW<1BZ)6{%Q}uS&n|cy^HBUFYm+3RbuoN z9l015$y(^&{NG)1d3lshz=At=rxvrPzKqKi%B(zY)}rFM;@+Eu=Vh;Jcpg&xf9|?BEeX)!NGV%>PEZpGpHo^;YeO z$8SA%|54dX44W~!=je{%MJBP;(;?frn%{6Dq#HyY?a$>#r4d*80?e*4D% z@6{fo07Opg{P{EUJ+$RRXbfK*le>~6ixDFE<3E19xYVNmb871gli4QAnODC47g>nIL+#s+?vLVb|1w-|_syA>Kiplp zzKa?O!Jzx{<;y+9P7lc4FaKm2hn30+J;`DWJlpWa5fY;EFIyIa4L<@oh7}mAT`{1o zQJ_#f+`_W=r@PR;5-rBQVm|!QoRX;NIeFfHu^WGH`dzje-v|4##<*xoVgxQXWqvg@ zi&^``_?8wspUwQ?!T$oh{OykHy`inaSp5Q1TYFG;R0&X=S*>Yt!9#lihf z>9_U=T>TNbp?G=TCHWu%oH}Xb&M(|mj21+j*@}p&U;h4kk@!cX!njK!&9NTl?;pOK z!++r?!G9|59F=+9?8I53A6W5yM2{$5-d#{)Kf%Jna#O3o>ebKUw#gNkiYYn%J&XM7 zGu9x89+41EZZ;w~PcV#8@8r>1xAD;)t%PX0`80-2d(zk1)8rWc4^nd0{|@ z&^mVi=R+C2Pt5Yd%R5TH`{1`P$46)JkMczd3~ybKbrfGOJS^!PRcLM=B5>yiR>Xz> zVSrNzFb#c2G)y_fQkocHt+%71(o~ZU+k-`5`$LV4?Uj zXzKMY{J@W;Wqwt+@4bnAms$(W*p56W_Vx9RzlG}ceX^5pGD{->MlZlbxPEBUe%@yB zDfvfS9$fm{$|_}6chZi%Gyis(|3BXlKYrw+ z@k0KS7)iMd?(^pl$6HQP+_`h7$&Q8fOijWgH$*p^BC>XcZ_NkvhlEDRMcfSlmFO}) zOg9@HM%fgZd3bm#k9*Szgqwr`D#HZT)AvYyeV4j&Uo#6;{a{WO$ceZU#ml}yQ>TLG z8?$sLDgDA*=0RWONHGb%ZusV#k&#-;$H@=1^zEIvE-Msd7~!%6D&@tV4?1tJ<1Q91 z4BtyLrqu3j7OMqA-*5DLlYOU#`@`2<`a!vBnCc=OUxau9SF`^INQP41vZhlY{&a(F zo`C)G%ZAYFjUnW)r+~gtrztHYmMY>wIRA;Jt45%U{l{g0WG{=P7B^n*m?ZMxzj6-!YGD2Wgt?Regmvan?b**#(ZeADyMnFqS*G)4>-_pnC;!pZ{+Ek` z3#{LBevLW(F-2aVhKfDn@*epd43f_D>7IYaha|Z6Qlji)-*%w|Xoktq&zE$H9 zoPZ_3-#6iIZ7n6MHo6m=NJgvuB^D1_W1eZPDP}x5uip{>N+AWRemCGM2N2#Y+vamU znT}yspw~24=e_TX^9l&47+_K3Zw-?=_qu7rH)~ zgmfa({KXX=C@KA3%*Ab;%Lvai8ql_-Tr5=nGF+v~x5v{)CusHXZqVfmWX;A*RP2hr zC%aZ;GmUJF@K;I402BD_v zuWtcFR;lcTT?D<5&HdH+K6Zngr#)HDas*M(IL4y5o0?N%*M3L<$nr%Nmb+QCYS{O40O-dwSE8UbD2-V8wB~CNPqt;>Q{?D9Nmc=pS*3Gsw+anW5RYuvsWe2 zf$z@grZCzpPW7G=9YetQvd6WHvr-%9?l4z3+T1W1em+enSHxG_u~)@0(o1U^7@9jB zywinNW!H4#6_$6@w)q?%FI_n1p&?;Qnl}e1`nZP_cD+DC{a6nkOqLr#a*D3apElEQ z#jk$Zo%f4*9P^Y}A908E5o_@zPfJ&cq9L`IZcds;Zkqh$HI+Ln50;|+4BCT-fIS9^JoI@l)q$*#=*DER+y<^Zn{C&gL?{&V#MnV@>Kf0b-A zS19X)NPk&cKA9Lv_xQFD7_|Om+T@3}&!N-t&Qw=-j0q*qQ#4Jpo#VWj2V#m^Ur|=e z2H=@)z6oVR+i2p_C(i5CM`{t3?-$BGJr&$!I?$%#SjTpFYrKoYq9IjSE!WzcB5s~&DjdGrOMalYM<~+jG#%bv^ z%Qgy@5)jIlu4&3PIAtiMc?S9^f83g{rr5OfM9yZ`F4W-D@H@!Lcx>0Sq|_&xk|Rg>4hTm z_4SJf+^?Zi75aTqSxx#FDq&8&CZ2H7?n5rh6nf@8j%_7SVJTy(< zJ$}ATx$;;OIZ$_k@?u!ZrJ7Tl2jzU*a2r>TM5YV~l}Tq}1j$*nTG=8_m_ zt?h6@W|qaqV*!Wtj2?(lZm5ciTIDnzPlS zY%S)taG#FNEq2jqp0iskmsB#1QtTr17(gMTPkqtaH7YpI*}j3)vmz$T$yPYS05zd< zl$HTbx3i84@^ZZZ+*>6FRP%{qSlPqSa9gLMogGp^>&Y=SG#Nx^>=oW?MPaa$B2!(x zz$)ZM8*6{IjBSId!IfOQ9j7pwc$*1Y`x5YX}kAqw8|1J!m-|Oo7I^Kwik+U)CTZ zThZv9UBvJT=p4L>$z!W%vXsOWNwSlmRaQI!jXEW)gaewJ$F>W}DQ;R&aSi8##}+*RZ_fs|julNgyo2U0*J4k`%VX=qb?{IvGcu(SJ0R$)ro`SH>TN8Xpi-I&d-aJsR&mocX*o+?~;}hG4e4v>m`6XP1 zOM9Cy4pnYE_|TZ&aSUsmx3d9jF^pEU0uzLv$dzi>`o>n6*&?B+P=7YX-Yabjt8Jg& z^dm*uDXY8!MJ`XW-znxZm`Od72Fk=44jY2`6NuHh14XY5@SoSkNG@MoZV`G+EKQsl z8+|EOM*d3orx)E1(PWt-?4Je}df~SZo+^3moE)uuiKq>3dOl})bqrH^Urx5^j-0RG z0?yQ(3yfQj3~8S6>tE^G+=%*2XMj*(g%xLzIqnv9&A!IEzZ*SEL?sbjFa)AnR70~C z-amvA736@E-v!5Im>9>H^6~L0@tQV}Kd<`ukzdhb)~{DZ>xr=-=!)pH$;UVIKyo#T zb49XBOZ7#0l#TeX<~C8|Nf1JA*tnVvW*X+;@IuXIg{Lj!(X)1u@gS8OJ6rQ<#*&zH zxz)U#fmz4qW=HtixvAF_WERDFyt7_bHhgKS<3?M(Eq6?BKwD4`rPmx z;hr*UByM(19$Ci1I`7k)JNr zI!~rqUTE0p%cr96=ycgbjUE1{UOkX`Kh&K-YTSb>=+;848&@xqza~G@EU>BE@iq1NEii*Iztg3<+ZvGGb*?%8K75{XS zf5sI<^ZKnont9944gZMA~`i37Gp%-HdByv&);BUt4qsl zXlRgxd5I{*V6lv3CjF!LI1AW%D*;AtbEQX6GqbmlJIQ_xo z4jAH#d2^U64H;Zp%K?JcCq*7;VTg%nT5?sN7&aglln&r(YvB!6G)!Gn zji4OsQskcfTevro|J*73ha_|#Q35OIILeys*f@)PiryWr<}vK@J&p3ZW-7tHFxOqy zIQ#C3dRBLj7~!K|gs~iu1BPUKojts8<^kTwSM3t1Efbq^wC>U8vT)qNe#w zUjA`^(b~n?zz{Jf)6E>BFU}QbLhqKFxOQL8E3X^97h&mr!?0nuI88mti`%fZje6ej z*<=hG=KA&P7R%Sw9m&i$xhb4BTBhS+XfEdKnd$A#pr3Dq6gXw+gg1NCVSj(*{v8ES zTm|JID8ANYb4mf*LB^>0i-Ll;u9hrYyn0iQPA%4}a6p^iBdA=WrlwBBz`fh!>ocqE z-nAY_wF$Ny#qyf8b4gUd z>MAaA9APXbyP^#vXd>!*JCO}XN;yjrP(5PbwHY7TE}^cjczrH`V!U|v?8XBlBQP$a zC*UM4?X+Eu@ovQCG{<USSIKp9^%#S~S1;D~ z3uKq3!vRta`BJ%?wy0$ry__?Z3$hDD4&Lg`|^(KN2Uk7CVLwN5WTkeyHIFq zHbX;|!n9~Q#?DOv1??ohiA{q}x_P^XB$ZU%l9s-kPbs%tRB{M43n`Y1Er8t4+u~Ud z4-0!X@3FnKm4dIc<{L0#<6~%FQ8rYcq-nP;?6pTLj=tnIZiF9;XPYl8h0D5+WjE75 z8KV^*d91>Zh&0OS>P~z&&CGjM4oVM&*%UZ*){SO4_I14%J45+nIZYs!baO;@%*2;! z_63^7-nHJ;V%YAyTPSvarz-Wmy$@A`NZt0!iKmvD*AA1@y+@1fDgXvY$E*8LRZx_@ z^<>ZVF=Uo$JRQ8BWQ>)Qvsq!&TI8~bh%AfTx|*-wT7ivFU$O0MPWnp7v7EU($nrdW zc>G2!!=RI?;BMMv`)kEnY+3Z7AWYQayKqD+g**oYc@ndj1%9 zK2dLyis1!0IpK{{OP>XzLw&;r&u~Cbjo>&^S$6JYgFWmn=vkR0rf$3R>;_S;Km$PC$@A7(Md%{+Cw=r z+0%<45si(F0aW#Yb(~yDZy@AX(@+|@k^_ioYk}Ff18wP!a7vaY22C?lFjd&UCtJ5p z5P8zWNwLZ%vk-?J3@pmOrf>8tOfVE6M=#gh)+V1D**KH{Gb>i>-m zWDxgLmd22*Z+fnXV9?@b5_xUAMggB?qydFVqpC7!*_VmD#KLm*;*x*$MVhMnX{yaf zVZ4FF$kbF1mwaQ~6}X?DYgvT-3hhfm2QXb0S=l=HveGQ;dfnE#ii$jdxn$y4WD|5z z(34bDI&*;1i$1nfTc7HlxGQF53d=XmuD@miVCb6}yyo-hk_^@cP1p*`8gehoIzq)z z5vy{yuHu8-=d(md6qsBZ8kX-aa|<8+Rey^=BBB?h*YUWr4tIJaCE)d^&HvXwi7&lp zSY4t2KxX#(##zWi`T`42XA(kS#na)FKbZ*sKoEFt^VQM#B{#+>3@Q~p#^FQh-?sS4 z!9-LS7N+%8dk&N<3Nr*d{&K;8-=lwU2wdteJ-l97mTnpweZxFbp^D;Kg(fz?k@wdH z`qvfsdqenel9ACGaL6=$f^CZM z&4DXrHjhjH51#4>B--MTlL!@?{J!8p^mv~RKB-(E7RkJK2JEi9Z;FD+1>v1`1m4HI z{SnXU(leL-iRZO)GXCM~y}p!6@)pmK_e}1Wj6hxmlqBmRou4XD<4*+bna8^-jGVWb zEV3@%5Rzizl4rj0*;zVa-Y>aBJH^-cqK`^Sg2Mrw>yDA#F5OuLP=2*ar-ALG$(6pa7E_}(!B=;n7p}wk+zUSqDUD+B zq?vJiR4A}?ng~;Wr=R5MgBr-$2V)pb$p5$$|K;qsn4QQ--Ad$}(A&vL$}%k|p_~IX z%WQ$pD{$_(^c4o#45yrvX8m5HUCnLRkVN+bG!bu7V-(RaglCFs7&?h$2ix%kJ(qZX06L+W>!t{y<4#2U6+5@aNC>=51Vq1$cNALGvUtEuV=$;A*G`smc++ zlI;fwI1XQb6VTe!eDmJTWCHEto{yX^ee>Yxrzbxj&r2T8gvRe_cm}AsjQGY>o=-<& z^<5JqYfaC|ulVs&j5Fad)#EUFPOz=_w#N>hEQTwRz^=?qR(Lv5Qc~&wJ~)d3JJ)#7 zoc*cPEF?T>{|J~r9vlUlO8K$je5R52L%CJ<+GX$Dy=$@gruP!K@kbpk7DDG*L6@DM zyaupDH6~1gf?an-Jm;l6XCy+t)`;wLu{c?iO;EZ8f!q>MkEs6*V)+M&d&=DoxyK1i zgB^|t`(I0ej##>c7tr|jZcEpu;e;FJ{rmQwkS$33{^G4y`1K@k^4Tj7lU`j$b~>6C zj-ICGHBzP?cs8jy1xUU8L8A7s;z52fBHBoS?wU5n`o_k~@AN|`f0o6m>9H!h{anoV|7rb;}{|@=pnOvvCNvfDP^@#`1Y?gwqSRFup9{_Hzi$=Q( z)($(Cqd%&7V(CG2l7A2X3yjXKM)#jA*O3Td+FDd@P=1H82#{b*=)7!^(V60V=7b{k zIZxQ0`eI8ImyVBD#=KdvwV2E+m%^wQS^4OuAH1{1Vmi0t0<@O-KmI6l&J|69{obITMT%dQH2_&Y65W-O4_f8e$F$b=!jl6v^U|)WMWWm7D60c@y(9Eji_>I|hR*psA6xk(qtriUEbpI%owa z1VR^-@H!yQwvkdkAE5eE)|JtSc)gg07yW|vJB)C&+QA(A5f>ZBi+n6)YV)pdzE;+q z;)uo|$#aC*i31P^NOz7?TU?LP48q0^Y4Ti7b(deyS8&A~jR_zDQJpt8hWLNpq*F}M zhFWj`KnjJA2L;;>0Z;+ba7kt5rlZ{Yatwk4besnz9FNS)8r@J^o_=g$j+#R9s3C*6TQ_sfj_d}ifFqQZo!#)@(KWhXlJf+UtV7=hm48A) z(c+A81Rq}wjHAS1)vwP+^;jGcl!P@s12PU>X(?}MtTkoE)86`}_(0v^?Cpc-r+T9w z($bDCQP1)B|4ecR5Hd+FJz=o{nS{Z#W8q+bU3bFSQ6-5ULnzo5B!3aZv&2k)4qkr zJ@0hfZTwIeaBl&7$ec_-M@J{LT;9awsunBl%LjWPz~wG`G~&RDsfJTGpWp7;TM3Ts z!obJ+ruEtTFf(OMav$V|As=VY#qf7u)ru~WUDy1oE`WTA@3)So_09vTjiVv1Qf0(M z*Xz-fiuC)7JU& z>XGvkOzX5n(uw~W{r?r@WsC*}hp8jxau?b6Gw_ z4*;&3x!fq-S2;ThG}v_ewC#~8FP@B0*pB5)rn;-3cf~;>gCviQA^Z!1SvOb$porl= zvi0h(sfzsw$rxf_w49O@=d^D?YKkk8?k!)>R&vHxm2e~}&QAD0z zt8nxN^$5qqC*vklB9GxS*UP*;ih>4BlW&H6hhy`bb#Xa)M4Kh=YDG`@>#rxUjEHa` zKI9eZb%}BDX4;MlTBDSySwA(s)w7n*)sHo_|y=!f<<@pi$nUN#A5e2f;S@bV3 z=i{`Qes)K`Z{zQ>ya!G&)%I()UsTUS3F5Tqln9<&r!`oY!Pr_^y`aO?+O4Ak8Gzud z5Kvnt*v=0;2As@Gaeci>)gMZ!OpGweZHS{UFbSp&R6n#DM`Re0x_-`8*T0(n=#wOL zb^f}YdUWMd0qXmBoWrh5Ev9{(VMmuRfpIYbfaeLS+Yo#nP^XOD&d+_|J$aU%)`_EJ z?$w~_I*MZMyYR!$_dj@zswTBH`LU?oyLDxOmE^!Sl)jKQp0{q^3bm%HQASWs7ME={nD{D-4B#p9 z2@tX=nfa^67jH0qK#BPa6&<%O6SVzgdeie>b}HZUQt-iX zk7p(}z+t@c^y85`_wFrO?mqR}TOX4~TuymW$sE@QHb0{|P&lYFYE7>8E5GIMq=<2! z%kc=W8*!(%MZ}z2kNsDKnyg1u*!soSVJ3lUHV-$KFZ)?~quDO0&Y4p1ZhH|=d@I$1 zI0@G&bUQsVQ>rp(Pv&^q4=~Nw=f??jPt#t(Sg+~h2YHmkSrM>mZ zcUqS>v9r$V(;_3`O;xW%HRGEG{JHOlPKd=Pj*rUH8_FSIy?6`U8_mt|sX-<0JCbT| z8&rxURN7zR;mX$Cy zrSlGFmO0eS?y_zy64)E;MCKjUopx;1TP*#@Nw}(yam`C2-*gqNpXiASeKeKdelgR- z$N}N6m$OT0pZZV6s&L>*iuxCMHJ0T7?3}c$j-L1>`9i01l7fx}-?_QX% z?Z5<{;Z<{juj&dEI$G8?)dozstep|eD>!VK^!B>pTxIk|z)yy?O=1NPG#d%pI=ASb}6KDs%LCZ7s#JF8Y~Ll1-QIO_9Zjf=r7 zn3ZBLl20}jh!5vaWnVx19f;<47Vy+PMxpbW%HsjpJD@@-ezkEVC(iqtizI4Ug*_&< zZ?<17KB%9tna{F-JUr9Vk(ODRAeFs2Hs0{8w`Ov!^eTJ)jExW$FPB@OoN|&%Ur}%+ z{ew`+{Hw(eWCyk*rn)e2#OsdYW%K;9;hF9bol;igzSWr^LC@5TGNWTI%b{I*|?al_BnC(WQE!V)_}5=?iC&M<1J zxyU%nZcP-Pe_zZRWw4-xO?UG4e7hrUCCkv06RGmKsA~mItI^1usoGDw6wD+P<0UXBjVNMT1@A;E)x z2j8rE*^xnGe5Tmsm|#jA9sa7=laeQmkd3<3^}f-URW}@$R~{!p6$J=hO84Cjv6jO5 zLb-ZRBnx4-hOE^&2HI?@O2va(lw&sZLQ}&Uv7M;d)y7JtzA%lb6FYN$*Oj6^r=8dt zkh9ZH#_|_SA?3I?sfuajMh5?{EYYr(EPS zhT}a+5cP9yOJ}*FAd(TnwkgRWhgfn&)`!T)viWyEN}o}fkmb}`$mhp$&IvTDVVesM z+Y^9o!Fd=&^9!+JQJkW@y~cRUq&rW^GApZSh#A(qw{%~c7)~g$cgw+t1yc|`lp5y; zf=uWxq5HXI3>av}1=eyMz5MtvyX})*(c8P~Hi_A2tl5reuY?s`9C^rEd%f^rr^`x# z6Z!1x7N~hQ55%64qf)eUqvQRTx2=`^AF7%pg2>=(scUX-{o0``AZN?MtKM_#L^lEj-aQmAkQO#uJIm zdDO-6(_69oBa_FR2n`4MrD{D+Y;%M#z!5e%ar2z z;sfis9?otIN%$MsgHR#(41 zM4n53znKE1XT(SL+Te}1HQL#sDw$8GtDAPj{f_0x4)xN^(D@xYyH@?!ox0XAukK?UuGjxFcNl!z&2ImV%#KK-T6m?lQdK0!;VUZbqNBHBX5l>8vax>sp z5PS;03nq7(R&2!r5~2o8b?p3vf5fDVyPvLkcUkx>^;L4)CaL)X+-t}Ij#PT96sFW6 zDE48x$GVNGM8g?Ra(~NnPSqqkQ{nU5>p_&U9Xpg>Ml2Cf?iSge3@#PjD{9a3R-$ym zZ0-hKpb^+Al&|9NOQ%gAdWLnvX{Lt)7XZ-YiFwm5~-;oTk#VW;y=kbJG&3WP0<1aAQU3StBApdxJad$81Gn zGc6BCN~nyBJJYtK#GgOn*mx33xxM2wQ1{e5aF!y^qYmD`6$)8K+&FsNBeFddUA4JEPAZntksd=#rPO*7VMZ97nVJH((})pyagLNF*j zNep7-Us%sqcZ}Ul2(faS^0jTLSLx2?%ZgYkhg=Q~49?BTTD20BuX4U;v&->q$$}Ln zFZO7Qqn9zeF%9b!1(${trq_yWYDLZfuhulvR`oWF7ZD%;=@6qS=qhyN*&0|DDG-gD zR))Vdj2Z_se|DNW?j{>_En89|0;M+RUo(fml%<>CorNG>h=| z@sNxON~JL+xu&caK@S2m*bJ)2Huf8ql9Oc`tn`|h0%7CM5BBq8kLnN898wa>d5Xe8 z%0e_FdIzu%P{QirdE4)bL=bG|^iF8Iom5cVNiJi=z(^EJ`9K(68p%>zBYL_0+NpPPc$;B z$v9UsnZ@9>=I2YW%O!ZKk2Ungof`g8@hFR!RrkfpQIuVZ8=9H<74dda94NjL`Ynb}1H*MZE*^Q_w@yw5v|%3T z+d+5+A<+z%$LhsoHmAHnykZ( zWL3+qjV=16p2yYu4SKj2u?JwwNzYv;>d=Nqhd%-euP42*_w&bF_7ACAUzlAwS3}Xs zE3NYD<+_gTuO&H6)fQa5P>F3FBOr3pRetp;x-r&+DLBb)3FoyJv#7*>Pl?^}_^YL`M27!vZvFkQ z!pi=5bL|9k#5~nZHaF86y$)N14nHaoCL5T7tl|86DhwY8Y&r+Kp^2}O|5~+@ge~#upfAuA+3fPATCzgi2)f-L z5#urTv=vxZrY*A_ek^`Qy0n((u!}-8X18`z>9V*U)Wqq&1SW2k*pKnRLqXM=o4RW* z#|h;?#?+KBN=&8X?nLPiNMcG&;Llz--83Hml0&U{x+PXKTVDyns==F8-4HY`pt?3exB~n=`?LM=He^brX{j9Uw&V{Rhm{|9k8Wcq81x5* z*_}n8!kK?>XRdj`CZDiK0Zi^b5oHxY!gSanf;algyY6(poaK}c#Qe(a_KkakuA0*!u>AvhMmoBMo zPk6XHuy5`-06gG$Cg|^B`YFMipnh|M@Sz=5L;IYQ)6Q4`F$q~N2U*q>-@7j#=ZRQ> zA@xe5Q0qCM;>0sow`}UW>5oV!6r+NCn+dQpQ*Vvq^qC7nya>5oL5sr`KuVv-HWu1h z<)0iS@)^e&0)>@+F=t!bf(fYEV_RKfnUs52QnQ}j>8CI|Ks52k-PfGGNTxP)J>1eK6XPw}KbJ(uC}; zc6pI~vL6Ji2|CvtyT5wFvcYIu{qC~)b@R|D(D)C6nSbL3Y~O#y(%HNwPx3)aLwc9_ z(kd>Z6r}J3`L$>m2WJl0h8{L?=3W@cCw8<{{>1Pfc5{Z^u(LZ#o3gK)O<=}6?%L?G z()pNiSy_+;y0&yj8jJGy=BAeqI28eVc?!qqWXnDVp{W8cuX*O|G&Wi?Mb-XU0%pzV> zBmN3F;3xf2X@Sv$SH#VMIk}$3TtvA`r~8GCFd8k6#=A7%<{_HGORX-v8Ec+LOEbG&?hQs0hqlme@i)@Y`x|WMk)^#GUK+i2TDjId7j3EOpfmmh6S?l* zOFiB#&C<>^vL{V9sR1^AOH$(h*qjargRVp*>(^?=bV@?NLno-eS7=p_iVj z9V=Rm7CL*e^s?f=VQ~DfXo9aSRbNJ(^t@%w{t_9w3dqjx;|c5o2cJkOIG5JZLhZ}av~3vUg^zLq%^)I@Z-M1H`fNjU^?f! zh!Dgw>AT6`6H=D%cq5CIx3O$toSu=?E*X^~2mm6qK!1@;uuo@%&b0}gKs*cVa!FiG zu5}(C zef7Pm(#ZJ%&-4zX-Vj@1O-?^>DErwWWdNkkF&3YzaM_x^mb$mV}f*u0W!wE(MZk$sP+I%70%8ew99?dp^#;$j|8ik6n0F9RyQ8dC=#Kx zV6(C(VRg>{#r*rB5iC5yZsQ#LZ5JNxL3B67Isde{XeDcBTx)(LNSdvS**Rc6RYA)&nf$>;FS^8h`o#1>|tQMe}#8fseMvh>J>0;J%11kcGXpWBFyIR(*G5B2V;jx zp|^XecgQ`v!Y-PBlXr;#cd{QrI?M^STNHt;?-~b6|G?#BIl)A0-iFN^i~9=J2gV^b z&5y0?Ju`khFe#_|x~O@ZF_g2}tKs78x#rDAF~9*%rx51ZmEoWtfa1#2VX)<~;icdWsPhsc|z`o3ABn$@76!BwlRdLm-k zKa9hgj%yV<^Y-d@T%Pb0<_|)$`BA=DiwFN*Fa9sS3hO&{5!J}Q8brx3xEE?^5m)fA zvY6e3F>U0r)IG?|r|!9Lnmr$PN1VL1M9?l@hboyXgbPYX8apc+42g&rOF)nH^X#~^ zukg|sXGhhGSnxi9wQez$@B{Fbj7kI>Il2YkVRp8Le<}1RiF>GS9grkv)yBGULVA3Q zV{t331{rfb`ZT*QboHIG7u;93xElYZ`UB3KJuq4#&Q*HXECo0h2sKMr8~5tOgk0sC z9Cp;tZeSpNbVSk+w+G|Na$I_daTQ$kg&19Y(-3}>Jal9k-^)u}A+09tQjik6Z&{TZ znuu3bg5&HL=Yn=X09Td6N>IP*j*q?JEB5nne9$$4@wL|~ILUxy1&8;0wBwLZ(Cf=t zsP~Ahoej5Sa!IoiZ9z?Eoph(Tey2mU;bqf%bO7vP3CKLTbVHEauN{~nfrguj%l?^9 zWe9qHt-&n#`YJ$S^~;wVTfbEj>iwb*@^`+nXdV}=>xAxNG7@@ZRU8CoI1fcMU3ZoM zFlgXrW#Ym*CsOP)r~COQ)ixIa^Ndhm^_QP%FUliJ*7F=GA(KpnR-)D+mSr7VAK4%7 zTRbx}QiJ}Xu)_tpsnh}!R}#_GSvJ+P8rqpsm*i}JgKqX*^BSkw58qi1y~D^>bkWg> z+_bfCkUDGbIc#D&a^cKegfScln60lo!15#kwteUnm2WQcaeQ(g;jVE8c{)1ew3qR% zu=$fDo9Pt)tzC^CqT6sK>vI2S7ugn>iY4KBvG*N0x9#Qw(Y@&n(ER=4m5rLv z8!y3j^=!UstTsBK{bIV+CO{&~gW8(BhSxXTd1~)|;l8i?OJ0 z2PytViV&zuR3cXG&?d>gSe<)YsY-LvOnSUAp(uI!4qpq5&p+$O^gk_s0`VI4; zhXZV&818%@YTPNu+BECU3*!j#M#ki{HNg|>EE##UI%88ItdERD>h`)PayMVGn_2_2 zpwheh?xD2K%X>>{np!8!MP|mPm%m40-81X}(titvPlF)hBqg-@xMqeqiY7S8Sv{Qe-2>-wT;*mhvmA#CU zv;}iPUul72wFwmycZo2I)ZM$q5_k+)cL(xe`IJegpi;DF6jEtT9G9}!(~SI34B-<2 zV?#2He#z>JU8I(?Z)2|K^FUu+m}~%9ub5sh#!JOTq_xw&Cx=>szO;pw_ z(;52HyB;BlN+tx%TEIQ0M=}Y4YknmDAbwIIK{|mr2w;}idm>Fbf!~<4(6CQaC^*lD z6+_Yr+P6876p~o!+5{AbC|tl}9U4aWiY_#DbEcaxSt~_@0MPpEB zppL5i$j1sKWk}lfK%K$I4K#=-)kZH3(uaPye|taX%NM{BLpQ!PlJn3^Cp8@~rAok# zY9~p30xUP*g{<`UtMiFEruu#n5ZI;ZylRn*BPpHP7bVDk-T5uzonBbu#ZPYv%BpKu zM#blz1VvaNEMfXZaek35Tz_2jxWni;GOR?ICvzgbhz%F}M+`T3XdP&YK_1-;((+K6DTHmcY`#l*cn1*Ix3H`&{%*1@`2SvXI<(gJC$2;iK#5{%-#fJc1zer z2#~k!TfbOsVhg>k9=uw7me0Z5HFkoCmonwif~<@+dlAC(15*dO+JX>_fPL_NT+Lf! zGa3Z-Y|pM$2pHavDqEKHLMCZCGHa7KUpz2&+*&!6clU0)TZza1_jqtT8Bvtjx0y#$ z*KqY|HgI|HMR9*R`kU6We@UsWn|Ylny1LZU3ODAn#>m|vbJaLDbQ@^I)a2v{0G3+s zw9*vKLUyMaHMF&NAZ-nPYo7rl-eTjn)>aMR_?|;whS~r!G}L*dEEBuM+IEaSl(ns< zNAh>3Z?95S@9BJ*JAOgx2JUs=N^`6871+(Wgv`Z+uLaN$rD8glVAm=qQ@!y-D|5}{ zpKm4Xwr{>&QRz5Iynp|}BVYp5l`x;|zXxk?0NK3T4qSKJ3!GqHkOyNuGo6l@P^5ht zTyXAnI9TBTE@2oT5LxTg2s40=kAJ2&0dohuJDh>%ZI;%3yL(XOW~sM_#;Xh$-7c~H z#m9@(F$W50ycjV*Lg6(L|151MoSUKjmw%HmTgj73V`XD?m`922;&<~J8SS|(3e)!t zu7Z?p3Nl2uzv$-`Ysv#_pwD|ja*}LWDXNjzZ8J|w@wlC#HV+``FJzdHJL=jJ2#_-Q zV{dygw6SNATi&CnqZ?q}ujaqTV3X8GF5LI8oKYJYoB_j8QF z{&TOOp!$<6pa4^3dVX3^(V>SIU}eau6K&y6y0cz0u~hdv^0`Ooj?;Y_y#SmPE6vD0 z=ZbLEvrukcJhwcV8NMaO?Y}0?4nat)b;`1Q##!$VT*Luse=87}7mF(H0t3{CIqatg zi9Zi$Qt33>-VWARViR-l>yYqcvtR%4GjY)*Q*n@qk~RoSl`65a3c?)Bi$s#J@9~w|=dduBXRZAE?0FqQ|Km<+o zepKO%nb}i-RdRItREfNnnY8rXYk*YMDv?<&KR~d zm?&=5VK<0j8wMe=QLNTp5ppJXQE26Z+(wn`jQR>C8B66D*1Y4EM@M(kz)O%9l|)Ml z)eUqLI;%r?)R51E=_q=~Z5c~=Tihc9^6Rg6^Xh_$lN)k+X%>t={=?0v0nn+zjN<(`AOVr}Mr>K0g7De79{sA@W-+81{$! z5|TzwQgl2w$@G0O4;%C-9;S5-}*lgK1i^e!sUvSPoAJvIej?n z2-G2Rx&7s9&T|^vKa7#9AXM)`h%8#}8TiKo_0i;otz`i7us-XG=Yg;$QS*F3?;_qK zx^0?TUwLEqHo$d2BP>s!@PiQ^EBTV~*h(wb|$&uo-zUi*u9u;kH+#n;RqHGe`cdz-j^ zjuGuNP^gZrH#VBbPz)x-vN^C8wiwBhn^lZVZ#BMn8}8B*p6c0#!;(+?!9)FXYL#tly+_)B{N*6CwHn2*d@x%v`A)u@Y|a35 zquoW*qZ$plE;3c%ekBh3OD^>Jml#AcY))hD&UKHAeFTPI9aKUnp^{vNj65Vm7Hgv` zLZVf;0~6Ww4x$kYBF_f=-EED50eOR$d=s93-Kse9%>9u5lF(F|V z*gPvrPVY?RPyT(5av>#HuvFC4%ocyC%-nZ1=-XYn} zPtXQaS5DB7*h-hSX4jFvJ(_6-4WvcTdV(wCOJ&DfY|^fAl9pWQ8qIH+w3Kn(@N?U7 z5vm0^7`>+7p2B>Rl0FH7bt#?n+^j(PKPy*PC$Z8*xG(a>B^(_Gp1dO4xF0v7)+MSz z;W_5`{hX^)_%tx1%K`YC7bd9Jwk{q7&RQf=CPaS{6LGz9nZ4Q`r5RzXqrDRX=3yi5 z1~f~dW4uu8B=hbT08~DkpER03?7Mq!dv7t4G)WCMalq6?8>_^*9SfAt42tTO&^ke{)f&4g4#zp8yqI57Yd3ywqbW&{13yogxUnx;VMs&BCvFAuv6~~23 zSE=MUI&Ni?5YNVW&8nUAyDXZ_7R0au=D$o2?2e>qqf4w3OWt*=<(;jo!ZPv{D;A1kC62POen#t4T8zb78zxL#Z+~Em73JNgB0`7S7=E8G4=* zVZDg-N%ro-M)Yh@Dn2ZfG5Ej(LtZG#mLOB8Q{#ZF+{<$8J{FawHfhX!O1Mu9Q`M44 z1O}XMj1#G3S(0M~W8-%BMFW`e%T}NQ(njc0Mq<)?t7PvI_oiN}B!HY$H#32j%xe|$ zW2w`{?9arNV3C$UCpfFOSZyHGreV-<2^EQ>K;~wbvw96C{HB+p=YWhee_dp~(6;-5 zZ&9bP$m+y)QGcF(F+-Kbus^FIC&`@FW8r_zew9#51f8ICiMcUxpWC!MEN#s0awQ>< zSIE%5JoYyzn{I$dXo7Fp^#Q3IaFmF#74%@)_VDm|;3Q25x552XPWeYb-2(7!ZW z7&{QJg{{*=uNf9&S$p%oVF3Qb6*n$Hd0R_JSG5vj4)zM+9nWPNbnn4(AOZKRTgC4_ zABZ-a(lNpeGU2553-_3JkGgZE*))bUr1&fb4CW#&e7ao*rT2kZ#%2P01sXD(2Wr}s zCOrE3_`)7e;9{EZT>B>nE;1tB?B!1q#y?#vQKA*k6sb!M`Eno71>oqg{4G1Eu*>vW z`dh_09Jfj)SV3!R~)M>UWKpn3+Cy-fBc*vx7}RAo(@PJ$rUHe|n}GDUA1 zV2}6~kymar)4L!h=3Y7rHM*o1r|X8u7(B3nMxmj#QROw#dcnO4*t+gaTl-$sO?Q!k z6|+p^V@U^yBIX8}&`cXh*T?eo^VvtQ`js_kg&Xt+ro0p!+1-!7|7USowggo0+JK5u z6M?pPnXQNypm#}-x6-u`Lsk_1dm!(rFL%iDSP7L0)I7#>M6^Z4I%8 zN@>g2xY*-OUVc5GK*F_leapQMuvEnNQ8CxMB2&$~-L7?rQ2&0H0=}LUg7p5LMr9MZ z>Mms`zwI$B$MdOgx?Pn)aj}b$KC=Y}%8#J?DIHeS>KAUT1fGajVe~0ooH)h@!5WV8 z1Wqqkhz24RY0gW<)G_ps>T-LePP{k^Abk+o>}2GNnkq0h8P1ztoFpNgzYlPJ0ARrr zH(+1u!n($GJFd>pLc~4+>Eaq*>XM3X@f~!LvC*@G2J0Y|b>qG*!tSn`ce6Yh1R$Bb zO$5V_8Uc30wGiJ9%Q|4DxF7}7qt48YZVqNi_egQQ!@C(>8uq9)run*Y8CnA&yio;1 zp=8VgJ?SfJ$;0t$zZflbPdQm2;VNRLZqQKfqz#C{zo2Cg0;7$gsYn8X|<`PaxD0QZCMXXg|~2b|{GPFYo4GzpAGd2IywI3k1X( z4}a0{cGwH8!n&QMV+z6(S3FuWIr(?FgAAVC`(I22{;M^XVM^Cess00}sB`D=Z3=b| zzct=}J$3TY4ZYkSk~fe3#q$4+&-CgZzffTIL+hxA_Y5B{*q!_HK;FO~eV>~GGwW2X zOYaddejtVc3 zCHlX0q3;j*pAm%KKEiT7@c#k(^Vy5dQ#Je<@^-XFa7hzzVZF8FefyFPf6CekTE(N^ z>bjkf46EhoY*~$}<@s|(_0N_u9c^2)^mJPJUQ7qE4&B>yNV8gHymqG1%7Y;Eb$ng! z{S;i6L;km}dQAnms;1y&g{FP?=OTZ$k?61KRj3xwN~6ck*7+cBPCTvO+v21Th(5jb z$BCa}3H~#IsWYo=`=U`?i@&!Uljs8L^nJ%?1{&$S|GaW+-E;qi;QJf{mAWv8;xHCpPmoS-qZ{+H^>U$Q{|zx%i2LB`7P{d0+)8LnbKjs84i zcju~}lXcZp1}zF+-_1m;N4%JI$0+*&PWJ+U;qI2_tJ3fELt1Ne;4oy&*Trp>&r|SF z?x^2efKs}RWX@RJPTiln2dc{_e}MPe7U#~Y&1qfLNtyFzk*A)bx8vw)SPyNTiaN8; z+!NXVTM;leQFRq>bS|c`ysNbBKO{ps9suzq=7$hWY@=0~vRZkr*Rxjp4E`C=Sgc|H z^;_dO!)sx?A41>F%^Md$7M=l*X;_QqSM|4m$MBxe@KKz9(0(40>Cz^oBEUF9`_ZG% z0LLZ!W8LjJ;S6J!g)v2KtLV48p`7^HJ2mR-vOZtB{#3a5*Af1IepR*oc-8<(1F?e) z54INiGsIux3wyQWEhr2P&EzEUJ=kH%YTn-z3GZqBMt2lzJee01ap13a)5h5qIN`u- zwD4PLf&UdajHf37 zJu(XS4DGj$ua?oD8}9xDWW2IJcS%789a%x~(i9W^teRoUhmS!2@ez$iAutIWkZWKv zF7~Jm=B1~5sE}ja+n={fHHUUbn7b14q}T(AwD4?Jags5_zhHUD|^#{xii z0$*Ca;F#{nFrLWw$X7+=`${9AbAwSB^`;+s@If&-TCDeyL$mIBP1mnF{@+TOt!!_) z9>uJPI6kz0=CUC)b>q+QD#6)@9YvXR$F`SDh4_Q?Q2sYj3SW!jTsd!;UTb-cHC^cC z#B&<5AvUbm(Bt~u(C}BCD{{c$@;E86sHU&g!sT+`SyrV@uv%6emT zTtry&ih4g5lZ%lmBab?!|2oF1XgU^ObaSgXJZYCj@AaY`5CD2f->v@I-RSEL zV?z*MTl)4#I)O7m1nne#4nxB~$}L+#VD!7Drv!HRj&;L`o(-gq_`>S?JN}WSrYmRY zUisXa+|o+;wfanU)T-i_`=&z3a92-CqTCi{^w;NJ+Ty?Yarc>iEIMe;?%6!FoHj;6 zP5<1BdgF9yD7zglOaM)wALQqxU9;G*0*Ak=A~s%43wW^(|1{VKH+QOZE4kW0TZE*c zEYKeQFqHEPB%-6c=eHjJf(u7syrTj5{Lf`>R^6^2|0917C8&O{-dW(H1~MCwcX((p zMbkACUQ^Up+;L7z(>l##iP>%V3uKsbyM>Mk6zRcsZlAg4=J-F9PySbMf?tcST=4in zz@+2nM%$1C;@87q2mmRzH@DDp$A=Me)dDKDM$qlaxY?cZA2#e3sWy|;L*=QnS01!? zB}zg|ZTK8l%2#gT%0ULF##^9`eyU?iy=g=g*4mg`uK77hpmOUNQ0XYU6V!wXyQq(%u|pkuY1%Au_Bj&} z!fq0`{wsHl=*Q7U(PkEP+I6oX?Qz``P?$6Z1A-<^fnhO~cmG$*_K%x)vF86}nBYoo zI}o_#=+F&L5&e@SVt73RZQaBGLSWYs?^MC9%BxSxKvB}2l>}hx@Kecam)L|9E&9vT zw3crzmoMKM>t>;nnf6?6j$u}t@qxZ_Jd$2>?Gij+8w0miye_W(8J_-e&Hr4N46d+A zD%o7=S#OGwwl2rNJ7$ye3Cmi*2qH23qlvMTeb;XvYToge(3Rh>`&cEI_TnGw>Gwo75U+!;B2= z(Cbc3uVY)U%IY0uIb4_%(W+fhcbu#6y3SR^==IgFpyIS|lO49!Yj|WdfmW|WA+9+f zsl=FXFBuO9mAQ6pQ3H*EUjWME(u_PfFmSKJdGLW*`O|QtBmToBqY}O=hGqztxZd)* zmkoI5HA|}{v<+Xn0@ExYfMol_Vep@Ej;c1(nSGdscJ;q0fh5HrmSB3_b+k<}NW@D0v3%L0Y@3ZW?_Dc>Grv`@yHZ2A!!yB1Z6uh4)I7Fn% zAr#KC4dEw%Pnv0iiCrW%QcRUP92pSUB>Y92L2i4mA^X#?Z09u5 zr3~erwz#$Vq=Y1I^kG8snuSqsz2ySW6bxS%g=)~PeEUUlmbK97I@54N2Hy!LV9~&^e-m}Cns6F;bV|Lh(Zeiju05#HA0S6eWeLt7^ zeh^qFDM76?Ti-35DReBWY0U1CY!R(QYA#~(g98JxPwsp*I-SkJvB!j8x!JhbP<&*( zWgBnT8y%ZYA9`gz?#ac!x*av9kL}wh^&~3`pi~^AP~1_SZcgbHauDWWi`PgX;3KXx{V1XK9mps ztofx$^MS`cQn9Sqf=Gob6I8)BGi_|3EuN>tTw|ud{ExQ(Dzg^Y9qs)Hu05z-f>4_2 zzBL~Nn6{a&{+mALC(Kokxxcm0YVS5YmiH%6xL5i-!a^jp4W>9!ob4wr!df|Ibat_P} z&-fguRRu5MZj!hh-iDJkb+DeX#N6{_<@BrMy`&Zbs=#MvzU%^H#h?cLnZV~zP zui#jp8+}N1_^XHMfBaeh)r{%kO@27MoKm_9Vtl%Gq>P#>OHuG>uKwfKR!2*t{P#`5 z>S)^{F95no5|A-;5V4}3tg&8GIyd@P+6K=x`Rl(omxd|Cu!rvFFqOUhPXBi>t*R-# z)R6f`lQh9<#MYFsf)o<6_!NN{77yj(!A=*SuZIQ)P0SC*(U*)i$B#lqsvUdZzCYYX zv%XLM_~%cldW`)|lnMs}Wo9H)3qg3ZL|~2l<{nS`=imHS!7TPvtO*f;gfCdM`tM-h z?W`fB_$_GA=6C5v$0OHvzEcJz6N=liD5s>lxE~^mD6dFM`{2~WviZwjmCdiTGeQ_b zn(9_n3eFuNewmj0I}7v`f6B=N#j;8sHxH~u(Rh=oP1PMuB8;HUm(?gYmEGrMju|zZ z4?7m=IXhq@&oXew$9l25-(`UV$h)qi692f=8ELv(3vBMmGHyAwwWc!e6X&I8pJn{# zvYdg@Im{fezU~jwGKwBop4?PPd4T6TRjkbP!i+2c=vqTt-YPNJD^$N z(SJGJ!UbEJY6+c>J7flF=<3#xgLih&UR$sl@@lUZkh`t;l|;3BlyNtfF~#wXJ3R`U zR92N&&9{c{WrUrQ0u5TR+}_~OyaZXhytlCi;i`Qo#S_v}uxWu3$qKbV95}h;6|Hng z{6+rwude6+1Dk+v>CRnatza;Z&E9VS&5xp&_N{I1yUW%RrsLq86Y@u!jz~`wPBMhD zv7Hl8R<3Fpe{Akz)KF{-l6kHAl`b;{c|1J)=w#xhDcioYVe;&6K75E1OI6TJpKOBR z_V$mTvfubDXqk@NHnLqrovejp$^A7M85#9P->dh>JZJ5fK3*CoS|D5m9n&H1xlmsj zLitrbh*pv;7+-$l`t@J4a=K>sFvp`y#ME#wUkOdZD=ZkNKQ+W_y ze?EQvJp0O#i!M_pqFI95#dgf17m7g-0vZm_@BNsKW_6CmhLBPxJO;Hd!8#wE7<%<} z2W@p#ozK@$FPY2t$Jx&@F>FKfJ`ecM9Z!;SzFadIT)8UkQ{Z%>3-hS^!v$KBq`EtD zOUJCi4+c3qc;eoxuade-f>iQOa9i{xQgDz#eXn+@|Igz=g< za$EoEcJVBgO2P_?i|bZ{vG|fsdb(+jzA}R7LOon%N6}Z7xi^$VC3#_~B+bWeM(7{=DG-^l8=(ZSbQcb6EW!4||Y z?Wy40)@3)BFI=KKv3u?|e#CBm{c(25eL~Ygvle+%(*~1ZqI+E4XNmtHaM#uu*mS*D z-bY5p9Hmk^e{foV#FZkgLk?zHqeIM>sfBHPH4E(VvF*7FLT9F(5Qub-_o;3Xx~jbv z3fskye%A0HrR3esPU-qV)n4S{Hq@(Idib`_81UjO4lEos<~D&qcf=MNtmU8G+G6mQ zJPo74)|wf4KLTT%`&}dF+wrGXRf20LywO$z1)m5m+42W(`m=Mr^L4yF$Z^WJk5y3= zU@fAJJH+wf52EJw4!<~KgnkX=YT`VkR=EyG6QW^##rr}sou8}Hpn|#HvKsD#YUe7= z9|i}pz17}qRMT_gc}oiM<6!TIntJQV0rRHb4bYdHD(2p}c=tpy!uUGYuiI5* z=+>(W>n3IzAn=QFlz!+RCwhF#=$04ImCZD*V4<@;+b8T1W@x?5;uG$06H(SboOfsb zYuGWEl6ImDjDy%NcKN<)i&d}E)_Z$tbRoNo6V{rE|g0 z(F>x2F#lF&qs@4+SW(Hmjs<6I@)M7#IE+Zq!2>Mc`77k`ryhSJ>8rkEKY<{_#mhRd zZd;ruh=}Tgz_=P566~Rw1?zxQmR8)>Np*|qoX~)p${s}nvF4@AAYucd=_;$@_D6rt zv@2&G(}_3kW;JdMTbz{^VtA=~o4!h-h=+`+^~|<>TC^i3J1y>y+YddsdH|g}>x?Oi z1I)QPzcKO%@Im*>8%_4aj06r{40;JB*5-laq0`dR}%h^WkOK!+_LN@{V9`20^W$c%fNq zZ=2C4s1L@Ahdq8;SSDO$1M~KKuq0ezu2l8Nw1MHrXV&d<_AaSM1X@mG8yUXr^e^O8 zIt7di3&ZD%<-(fJ*zF3Eee9dsl@ycSoU%%3*W#{EP1 zELvJ-=D{D+#|AnA%mEiPtp!l6XI_Q%c(og>D7N{FiL7o-YFwNkzz8q#QlV8JQpML0 z@6z`)?mqE9?y?d*ux0i#Y1)aLHuyX>;)PScNMDnMtm8V@!VU{{o))tPnT) z@Y{dS{fM9cC^f`u?@9*#F-oJDb-az!=om?I1VbU#rhgcIJsNKvT)w?JIw&8T3~n;2 z_a}&i$-98;a3HCpp#31~;`LN{UoW~hfXA5xi0B%)vYcNG z(~$}2{7@UDUG(pjOD2ise%L(YS9MV_+)BU`;q7>$L>=V_p^?G(ab zLyggg5nesbG~J-;X$=NoklwHf*@-up<$i>3+>qp-^dkOL?V(S>QoIO;;1cD&b5w(*Hu zbuao-NDnqi%ZLh^3=H`JNNn8+<{Yd>Ldm`Gt9NUK1@(m0E3BEGQp<_FfUF^p$-Z zDSn_BpPnoV;?RkA=2H}~kTKlX@iMl*{NgwrD>ct&MR-kz4`kH#Vd_}Xr%VKw_d6-x zviy>e9kX9H%{gOk`g=9zm*xMel>8rm_UcB)(I=4kuSk+3{Oom=&ggG)A0J*Y{_y_g z6rAq?^VcQLIfV22`eXxvSki3a`OJd7)u*m>7E?7aa*2`e1|zS>^9j4}!el|HjW)$_ z?DcX1yB1|HxAKHz%i^Em}#a$?td^rn9O7t#LKYiKo+aEB63q zQB%(;Y1K`vZwEJS5yx}Q zDxVSaV5E=6Q-gb4wZZ$puBJNNB~BPfonLV0qUWtfTMyr0P8!h`$(w|rtt#@(+Avbj zvu(=_v`)~TPq)lJVjD(35fqiDU+7LJoZhx)^CR!Y+Z_6ecrT2yap@0m3)dzyoLuv6 zRl+Li-VMe3EnIMHKfahwm~i@Lorku9Mo$D#cO(`@>>Nr?YH)CHm`C0njgH7qle}~t zcAkKd_gi{$yrMp_D82^ksh*2#@DEK>&wD;uGOOo3%h~`1W*J$sA_?Fg=O(cg!qcY0 zwnJPi#;byOS_duFkvZDh&|NoGTI$Sr{-x5v#5ig_9kWe#CG@7{t6;V!(6f4zBKWlx z#D4$5zB4Gx(}?ip)E&uHoJ_Q@itSj< z*KXNKr{f$5u35fN+V#lzgpM1fl_c}6)&QWGVGlukUPkf-sHRC8RT(E1eHsJ2ZeZel z5Jr6;KoqKFPK<4{UwjKRsM_Jf7C_lS+b9(s)!lD)d`2A;G6- z82Lg>X#|vs0~H%;VI}>8m~ax~^fAIX6tJF1=9%n!@&GpsgYuR%NaRMmM7&6@PEt(g zy=D6c^A{%+rch`#kDU{vU2Fkj2LxkK>i!6INOR9%8;3#Dm^;^<%Dc#VtzvGF^pxym zkmhiSnVl~P1RQHmc=xSp>Fn5P5Xve)*wu{lu%<2ut z(zsYc+JpTnLcwb{QJ>aG@Fr18zLE-H8ki<1>(WZGihC8G#C_k%*Mxe5xN$Z8Hu`we zr_Sl@2XeTZk4bdtNxh<&UV_hM&O|gZFn(P1^Q7v*~}7q zcWhg-jG+&~Rn`zrPP{y{GeIFr!xjrE*yAhN$n5P(!;p~X%8reL{rnV2k6e$p4Vy{w z&8CQu9a2if3Sy5d2J~+i_@M|Sk-~S8P|MiUZ z&5fhXLoR1V&@DF#!y94v?ES}qr{$|R)$I$z?W8uQN1UMJdB!#5rvn0Y2j3W5SrSJs zNm0M=PYTr*yTugV^Xpj7hZv$#fv&(NADh5nhc(yj+qB);`Hib-9&f6~W{(M-$^LfU zjca0v>cnpBX3oC)SySxOtY7$rZB1Hl)A|cZ7T8Y*6CvxyfbtXtzY_MUDr1=g2(6WK z{%oZZvxZCfO&CpKBR77vb^kqGSh1FqU01>_qp0l#3otwL8|OYs`~}GpZ+-DHE|1NS zrnNmH!yzVv3^3)RhxT&ViOSSf$+}+p!AR|J;PRg~)YyJ>xJ+8k%*@QSUy1s7)!!&m zVbX9Jm3K)Kb|-)BB-?&)t6{9SEYHCuMjrcaz&Z($CtS?4(w_)VDnsL*r3z^$wbSxe z;0$7b0SqSjiTs`8+r9%UgSv{eTBmgr8xU-ufVHVv2SA?6vgoHaM$e`9VXQE`+1yvJt*~HVxsn2X}}7;&By*!?cFAP838S{3rXDL zDP7z=N+E!aq-(%cQ-n9$lCK#|4l~yl3q?}>Cb0L`aQhb8D1Za(8tQ;{q+`C3zC8Qv zB(MofcoGhr*^QgNy_KP!_Dw8YT4{bp!i}5oN)p(V+?(7%0S3iyio&Qg=tkY*Jr%IC zLR+Ih28UCGZBJIJv!+&+vXjl)X^nYDAx+=HLpTP}YN3QB$R~~qbX-oT=lI*+%{W}P z?ckt}5-RA1MRgZbQpnTNHot^Pbiqz#PhbOG8P|KI@sum|rI_u?vdN`sz(~dFE{t5a zGSDer!+}#igpn&j#Dsn7>JysPdPd!+w^Rb1l0V^AT*=y&8CbyQ#a_*G-j_14g<_dQ z)Z44Q;&`jcw!d}Zry7yjP>MfvOIud%AmsMKQg$oV%;QHGqj`G2&r5@R?aCtiaqdtc z`HN~YD;0k``7!SAK3*qK$L+=y-%5r3>41dj)^JoGn*u=t)TAY z2@iwdNU<7hV7~g23B@^XDqrhKm%Ejgw4}en0^h+Im{-2G$bOXUB@G})ZdC>48jqOQ zgfuB5grPfYgKbH}s@Xg;1N{B9xYDf?RF|u;>LPZ+YTh?Z^X-#P4iiPZ8bTwsvi<>f zRVP~lYQDW>&=4^QH%vC3PNj~6hkdlubx(1KRrNRm@2-%j08VRMWiqV>j5^2sZyspg z%#45Er3GS#o!BEqF!L6Jj{mCcRWgZ^!{5H(9F^V&XVm?n(yxlu$m7~(6>7wlJ}O|> zlRmRsa+1E8)T5Ba-shjMBx(CX@#*(xTu*~WMJ!9lQ*DJW(tuO0!(viCqlE_lhqCVu zq)g-p_TTY7$2qU@+~e`&hDs$IENfm}#bGB$lck>4R6$2y9gkZ3Dmotb zA}!r}Z4#*LEIx{sEHZ;2Y@P5?@&}b^c1JKX#^M4W;`ayz=|@X%i{m~3*rTnauGyl1 z+QqdVA7zJCnl10s%&U01wH4Pr*BB7cgI!|S7dG@>`IL34fMJcduJSO|V3*G>Hh`$E zlGul#Dy5n3QPUVI_&9-S;BFAdZ4C_hmFg^{*neGj_P)6M;@5VB~Vm&KLh-;yFX9qkIlsV|y2cHCJS zHOwQ)^x6#T6?$h|S3A>r5C_J}y{633Ugj=IZfqZl%He}b+4$*Jsa{gjW}I;2JjQZ! zS(vG_7@iidDj4~TEb>xwq@RrM(MuAOtCrU_gwMXD2nz8R#E;h*B*3f9cU`QjL7Of$ zXG=H64Y|5r^+L*>VHDu^v5?;5)EV%!%M7*5l!^i>o8` z?oglGGwICNNn26!#Uz6%J-#0iQdsSN?bIm{T;HPc%F1ni>!^_Q=2JEj>U@$>La`p{ z!oyBgNmYm{@t~i3nmJf$&EZGV4f7^!ZI#EJQ6jTEjb+b}TTgahUFgYefKT;2P#i}gLkXPMWu?{+^2WsgAL0Ik3E#}HB&KJb}uP5NOy_|b(#*FhA< zH30qGm9*Xf4oz05G_KR-+abyL)%RxF=}j|e&IAN(B`yKL)~Bn9mW5x(#Wj~ls)}AC z7um*KH%z0@!6x@yTwI<_i4dWx3Vf;Q9t(*skp-rEk<=8hjrxMH>B_m-mkcE|tLs;7 zmEHG3sSRR{Va&5d%ZpG%l6QTmnR*qt zg^Nw&GCJ)Odr8?uEI#@8T9v57_)uY`y{yndvaRFYCp8w4k}5Zaho9jVggpjvm5Q|> zaPC#pQ(cLh)v`Q|p{zeN(9c7kPk5j@lfdtgdW^kkbNr(#O$6Uf1irm+cQ9pl{be_r zkF?fTscenEmYb!`o}&X^cACEMP-1g~PG55<^tWd}f1~)hO({=2)dl(R@!*rOdZpAT zpo;}%>pkPw7cwtAu9;tW1)?;mtZkr(@u+IKBUKV4ir6fagJ^&8Z#Z^l90?$0sHqz` zY{VN6^SCD><1C*YWv2U9Y5Nv=vkRE}@KUhxiJoVX-Q)GbYUXOdb1j2r;h`1}QQA zBAGwNDy*e5f+4sW|hv%LLUq(KXzDYc&?@MW~g`Pu{r8{@VRYyM= zY#YaW3{&Gev{p`1Qva9hhL7=Y`oL#f7ilJxVoG^O zn7f8-{Zi}|dku^?Z|dM4D)Ck>G4*xKYsnESHGFq1>(=57=VpTG%IQ{~m02hkT_Y66 znG8RKwk==NQ3b+&J^AN{itQRr<1C*$MJy05zjm6!4?E!lNyOA3BUjw;d-?#)V85^^ zX3Nx865%{(E3pOTJ)4A-#>94YX;?p2?-G?v(S#kzFl%$|k?O1ydIj=em3s05cM;0l zPLl8{pR_Lg4CK$kTyNMTj#GAMl-tHp+oONvfKA?<(1^tYQyU*29X{3Ve(=5XX2tfo z4tEe(L+H+p|M4|fiQWgNw^39C$yPd2Jqo(M7V09~A2{SLK542)kR{}2-9_CSudLq; zB1p^};R_^=qv4&;7t^*3yQgL8a~ff&4%0*!u<8YC8Dy>j3FXu* z!bVn9if!tP>aArhowFOOuDuuh=+}YpGN@_|sPRG2Vi&&B`f4lV+NDdR61`CR8O3-t{Z-Npq!&%UcqlGy6`UlvX29uL4=sr-h)>a zO7OzcAQTph9lz``w5qQ3u{+5!3E9i}_~=ajC5dCd1*~rq4OoAkTb5UZ%%3x?5a$N9 z<{fo*!N^LCBaq*UsX;w$k?l+xp^0TaUtJW7Tk2P`>PgWrNbh+O6$RJrcWL?hN-%iE z!#_=KYO%h{K7ry&qA47h6k#jj5JWAuUV)z;*`j3;YtxcHu-BY82p`$9#U+Vu_8>6h zVP+vZ>!Pv#qB4b$r)7HqNSI2#h{pj`*aRJL?EQ4fFOZ6&? zP7!p^`Bw;+op)y0EJiwpnv$kZex^(JxGhe(>`>ah^*)1ncfAQbDQ>)EEB8Zp*w?S8 zpfEtMQ{=VniM1;8G7?=wyY8*wPzCtMhR)}lcVDa9}~@eplIn0k4~Ne{Z!gI{u)DyJZ|o7Nq4%HW2xdWDk-}3%EV(K z&vJXzW=*!J15XKpF79jhggU&jRpNxWwcR{#uCZo_sWevFbKGZhm~S+Ex3#h&@_~)h z&TDBdudW}e*QtS7v^8I5=fx*qX1}?&@_5J&2kiw*$^_^IP!mDz3;dPA{RYJN-!tG8 zvS~i6tbBZLX|%V7fC~>r$?ax8S^_iCEum1$KV*M~^DX)G1)cB~tWJZr%_=h(?awsg z=je2n%Nq+`=XSH6TZe7GiHo!9#*@-P=bH8h)RkMu+jJRoMNbE*Li{*GLPAEG*`83W z+)ECOI9^m~d*Aq&$U5W8MIDPPv9CmK1k4;g(KcqC${EJ$oxgXccp16S^(NkESVS7} zOej`Ln8TbhuAWzR#eL=Zk0`vf7%dxaWw=v+-DEV;mJ92PckdpAx)4gy^Os$l_>Jp_ z8k2;LhpQ%*b-(yeVha2;_3Q#l(hDAVPfIR7)>FfIYlYJVHRTd4!{}Ixo9#?t+0^lA zP6MiPSD2rqOF1ODnob9qS>HLyKHOKQTMPs#n_B$$@SB^pVm4QZ#&{rm*Xb$0KU4e(LlyS^0k{7F(KGMb z>`ZfT35(|rOEf5@3LXU%F_tSL$H7HMCyu%jeKRt>1J?Fkdt>{#iqjJlm))pw!piw* zN$G82A!0lvh4YswD7+>s^l3l2dHZpVV`{NU<2XXSt+&d2yR7xHMMyc&(kok?B|i5Q z3;gZcD5(JS+R6|%vS(@Dt?|d!4e~mE9kfpWk-gZLB@i9+-dBI4vo+R93ga9t3|p`C z3 z{a-NVKVO|Gpt%0pV=J~_4dF5218-baU9=8o6qtipr!C9i({_M-Xo^0wn>1hoq%3ZK ze#xC;N~J%ZP#DKNGh}BvURgdfX?)>WMUr@YK-3isrSzIRZm~oh#h;h%k+i=!8#e#` z)J-Qr^Ln;ZbMy14zs6w4!Z7E8#=xL zY7(LS)eaL+h70u`)R^m}Y8NTk4Z2!m%vw?`w%HurWJr{FE(|B1vh1$I)Iio zjTc^*0%{rBZN`UWDWT&;pJu<73wyF9N81tWwMtk&AGD_UZlyac9(5}A1^UTXUItKA zaH)4ATL7kUp!J>;81^)4@x|1tt6Dl56Di20r%U12<}26NJ3YPjgrQ>;x{+Fgx*0{p zBYVF88FHzxkY`A*B^loW(vN<}XK}g5Y7(|49qS|gtBVRen!lBSLRNLOB$ctAWuDL{ z)1BLxnP<-^e$_An1xMllRI?7>Cxp^H2tQ9mvLSQ0KhfybsTyz$&o>hi#=y8QvzHVb zmuFcSb2SVsN6+W}M1}R2C=|CyKkF{iv^3?wZZ#iIdWA-U_HwAW1NEIuu)l>LoNpYo zxtU&t==H9u0j#rgh->kng3hTXWB>i1_xPN|+9)5B+`nELsCFjLh0(jMh>!<@k88EPx0lTHT>VI6 z)+K6@1d|StAQVtsBGp>VQr^DN6@Wq1Gx6F6k6e3lG#TU9q@_li6&a2y1BC-v@{je`1Tj(=3X5CEPCHJ zp88oMRI$~7SOeH&?}taDScJb$hoI7j*Y2UB#8KWw7j#F7&?;5d13_V{7a*I4<8@hC5(_}1Pp@?PdE|ger?{95ydIFo=LcCKGO&H`8dVvn|FEI%THX(+%ug7)r z9^h6k>almVqVQgdC8(sE@On-8*vjK&D_LZ9`7IL{L)u^6i{2^ z*sbmZxt)xGKxq}Axh#^<4mSW>> z>&GQB4q(`5Z#g`nw|ia8>=Wf!i!LRbjsF1}=k|W=Yt&or&MrtmtXPR*I0%?JH|OEA z8w*}-j|L)^=!zGhR{`Ufqx`bcfI<;PI?+*PqHL~s)x}>vS6>HyJeP=lyM$>@vWFqJ zBDeg1RqebCP2o<4#vb3rc_xq$+*6a4iplGcq9cPV@d}Szl3x2gwW>Oia}~vNfIwxT zMl_3_;65!6l;+iWX&E(C*!R4vNwLL$UZIj#$2ZT54_th@fy}-Ukjzgc^1Xcd@@=*3 zHlUa^lWIKSpWNP2FI6=xn@oP=`WCO*Ev7+eYhdbt`Rh#)ye(p<>IyX6C*bhTVU4xP z0E;4cYlri1X@$G#3SVlRvtX-dg}%d@h@b}|vEHK5xsZzu-^)9T!+f051Ad0QmfLs2 z^G7Xnr+IhKth0H2K06XGDtbDX9+PWU#+~LnU{QTs0)AQi0at>9%?-t4f`ko6gR^Ul z3<`z^c+_tH|-#F!}0}njm_Xk zj9D1liu1bMejA6&fgfJKS(A2m$w&Ljv}W)+b8fbBm_j6VN1E667)TqWgc|owWG|KT z6)iC$5|Uvc1Ci$Er@3W_m74?3(!rqBd%+FN8V$4c=;GljiZTRES)(THEfZHF4un(lVUy5Rcx1*9%dV`+B1I2tM;PTPB0}Z2t!XPI<@CjV-vG zoE(?n0`q#amQb}8Z}eKt{PO_Ppb%!bD1PY&P(oBgu!}zEZ)IZuvX3_=P}Z5BJ}G*A z*7X_gy$jT0^eLb%7u;C_dSyXJjNoIW(zncPEHHok0Z<0O7Cd@uL&7y;IIzy2ZM2t) z`SEa==OF6R`pQJT%uw(7%+f_!F3chW#Jj!z&C6-+`?4ZSrNEuVM!_0(t@`l~G^=^I z=AZ`?%6R!MBMf$S3VWPUFoA`xe2Mj`vXYo)u>NkLdo8PG_TDhvO z4P+HQ;NQBMVP#jtVf|KrMSi~s9^}=Tgk-wD_l>54;;c4+MiO+t|HN;Uhbm-LQ<+13 z!f*~i5rkx~?$uOLACDZR!4+gwa>lwJ-Up<(xt@O~)aPqdJRl9#H&4+tFsTFCb&Xqk zesH18P*!OVT?08_WI-Ka99qJgAIq3@iTQNx9MCSXcg26BD!~h9q|oxlW3aG5Ue}fF z?x$V1tb!*cnGBv1MIRgJSGR$3z0QK%q-xNvJH9L)s@#f(H9RCipP5!Jo$F)o2TFYf zG0BDL-hQ}Y#+wG$N0Xvg8yD)Ai)?3zQaZ9ztn5SR;!qM$c0r4|eW!xr%BT@-T2$lG zHGLaib?`97SXAkOypHDM79@-KG80}lIZ|E8#-Q69uzb=^c`=qzcyIz>d!ch!yc>Hr ztDO8s;IU$Mdv^5h3d7RwB6P}hVfeF;GO7ZcnEb9wSkK>2RF=_?-sa>G7>IEs8~HBh z#8RX?;ytJRM#sNyUf}a}P;@(Oome1X3@2cpDSsu*W+rL~K-0#%5 zUDh%^eQe0CHXh=YgY(DY>SkcBKUjz|rAM7HjDL7+ThmDRBD40Wc8R~^mLmZcq`IKO z2!^;u^rFnBiNSuQ{=X7`lV13#_ZV4B?wuc)rUR1)2cyVeT`SY!UXg+PNw^Tby3yPO z3*KXU(A~PR&7NTHc4i&UZwOl)q~ykacTvq#!S7yoPbPe}jVlZw;Rd3dghgaak>_P(?~X5bnZQd2coxD$?P(E(>Oca%SPKRh#FddEIsqG zIQnj+0%&VtU{H78!Hid;R48*sbV25^1@8+xQ?ck=w6&F$!Qyk!`E5soZtdPwxm<6X zG-}vQ!zP(*sFnL(KTx(m;7=r|0$nJaJUpr+Nk8-*xATHTm?;n(XWDjaq$ft*p(~98 zB~b7(5Czj~1o!&UHoza(-JY4v$;(j%T%#w+)ZQx*6o72~Iti3U+m4o|7K==bo2xNXoGN84%nqeJqM?#HT$bXSbOUx{*sb4wgw8!A z)e9kMf+8RR*NRO1dUUZ7Wo)E+yDzBi1yomM9~E#>tb~r@m;dP;{eN=X3JE}mcqnyT zH<_*)Q337IW>lf$Ckw&B^eH&faKHx-Uv7b&ndnT#hw;}RfPh5jQPY4Dr4!GPBzuZP ztqt)roPYRjEBrWDKwtFrR-l~b?_QCnLQF!QtTFHpCYI10<=)9 zsWYjsmxnw`sgTTfzHaqI&3ln8W+W#$+jIGmvqa>>5&;%?A5al!mL%`Gl6iQ6DMs#R^+e58lv_$$sb)%FWoc4oY= z`A3O+OHbR4%bjYrbMMTqrlfQFjP#Cp3?R0n<-n8I3s~z{M?8Y{$7)iUlh1zuclHXh z@1QNuEe$?<_DKOTlI$N*FA}S=HdrK+#yX+?WN%Wlyu$QeEb;M+|8{+Uz3Ve_M*-l` z3d_#JGD$)9Z2sl-D*CPf_HXNFo6;>Ur7h?8U~T2%Cht-?IYpZ|XwUUt^lWmIo4>q# zYvH_3ka+c1{MokSL_K16XXjf0`2N0;kr8`xX7A0Qfq_R45uqanY`}n`Z755hS$xtF zCsNF1{hvGhJnc76+OSHIqo}_w_g~C0^jcxc*Rag$YY&Lrm0VN#7$6~Gq%_ori2-vx z=fpyz^pAb1r=J3^)vRdN0405UEw46Z9mmQ=JEGY4TcXs}qp?Q?vU>-^)Ls>w*Z8fr zgnZefyrhvdQJ+6hhnY$Z`a`Bqgm-@!tOksSbHawOvAo9e8{Hw!*%+{>v5b#s;6 z*6%B6=NIkcCN0CC3<~O4c4yNo8=c6}3rVIQyZ1=it-w)Ow0g2)0B+E#I}S`&Yr4+G zrRMGJonzUR&mWzf%x5>!H_`L{5sv|+ZpB$qCFZmE1DB`f4BxXCl{lWXPsz7ej|E6? zCI08a$er51Jv*6xq#3m_R&IHipfgNP)^5VtT7STr?&aN*;o%dAt&57p=VO!KjTsRl z5t>QctQR-gL~l&;TO^3E$Y>a>2Yl%A+PTRy-q1dt1|kjn(Rs~Wdik=5C|}?e8pucY zaB<>-J@o;ob|-0|5AHAz^-@ZT-)mLTmm+behh0uS&n?i3jRtY!cp;7yI9B*K9g+-M zIGvdITcT6Df}%_mAz4i>EUJy9DYa2wP4h<50#62)oeo4186hS5CXGHa`ZFU>+6e=t-b&_; zdhx~faqyyq;cHmT^#a-3DT^9^OVH%rc&?SP-TC%8=JB_Gbv^%!0oVf-N~nj7*(E#+ z_^{B(nzeijkD>v%{B7EUmFiL7pFxAl_$hPL*`BTORPC)-?B)2Tt*tHPD+L7wfS@b? zMMFkLDV{aQe;Tzk0n+c0w)PD#FE89ef+%)kt!MTeO5$NXsshPhZ&`jG)<)m?(_zQ7 ztWvTOUa_WPEPNei7P&Q!{ng2gB4q^usDqMk2c$^4m#IH4$ILU!s!AH%Jh=B~K9}R*a z+jqSBBP(H8Nvlh(>=W`->8^=~Jzt@7MEOAKqw+fKbGP4?IB-{Ci*Kb2Np(Mu##elu zo%V8>aATNr;1MA8YrYzBh@YqLj^58PoHS0cMJKG^zGKC(?OyYQVaA~zW@zhbaAjGwd{@5t*>OUSpo%vCN0x01Pn@h;+JwWQInjfid( zD(~cT<&V->DCWsTG1Bs$V|SSU+FC0YSoig`zOG$w9G8-zYC88l5Dig^)kD)pq81QF zzT2G=8I4%)xeT>T2*$xCuomsG=iL?gGF)g+e#m-hbV?{wpQ9owSULE!2$}2qJAaJb zfHHrFMF4*O2l>v{pyS*#f>?y@z-@ieS@E-Q#adCS^YL*voO!5w7G8}kN)E0Gis)6Y zZ4Tz1cPD)pD7PdSjLC5u#!Td@b_Q&pp(Pr0W6|DwO}8-v@Kl_IxkYx%mkSd&_ypbx z$irhxki~CfRw-;{YIB}MF3sq&Jh;-Qc9maCK z0BHAO8&FGHpZYomLF;&Tmha_B_x_fAzNQ~I1G|otdQE%yOkUg%y2VL*OlAbzBYRzX z(K^p_yF~-Ur^^els7y9MVm7=^J)h6l`Z(mQcEwD%z7aKT|K3VXSLOYa3FT(T9qU$wUp2yRX68Bd79PQ0h^vuKy^a{`OqZZB*+(EY(iqet*jdl$EN zgqkq<=9#v;Cb=)eb8wkCCvd_x`0nw*LA;0}F!qNx2-&9(QTH4KSxJ=!Zzg1;Hn zDj}QM7m@?l8^Vz>tcKydCArPUB^^bYMMv_s+ujYCbYHJCuBXPv9gOKKwRpIKmoK>1 zGu9SvtO(%`kXhM#jVJDR3Qck57>0iSoMc*-9w+Qo)f#hCe2s%>a#4WwCfjvQ&DhzU zuc~aFUeEqc2>+WR|KE??eS73WK-TiGc`>=&a_L^=_A9MbwR$ZL@rZl(_?mLEczdy7 zY4m@ZlUCXWUnG+`J;$z%VLOL5)sUrqFnh&kRlNxkq!h;UcE?sO$H%be?9MM*&m@$(iDEEQwf-uxd4WF}@eRW2iy#(gE?DAu}eDkG#`TSy4Q@yS%i*87T2=F)p&grHR+IpSVs{^A{Z$_34QHyx#nCAbdx4m>t@0d^nr}&|lTBsW zZoT=pFBY?UGRAjwq-Y@YM8_>RK$19dokL&ph-{TH*lddrcZrW^DMrlq`c@)8+Q z4ls@!C6KYr`QeHR^-x$I6@Ko$?SsP)^z>n1{ropiXG<@7v6{b#7JPh4wnQ*UI<=i>8GUZk`+!Je~z z-!zYXoN`3>A6K+P`M%??-JFw4MzPlGYqO|yx>x=4M*)h#36dj10_Mm#- znqHr{in+iiMAot*N3_7RyZM0HI_YP2k&&GIHl+Xp0d3JI(%wW2=G|W(2r2hlHg7ks z)_&zGcA`4Sxbd9uZO%qldR6p*<^Td4U2N8(8U0p1ohszpM=8v=N7tby1od&qt-fsk zPI|e74OnG}KknpPgFH$2=2xxw9a`eo&tLDGZ>cb z6Y!rGikQBM*d`A1eA|GD06l36ogKWTN0~q1M$1Y4-6^?Ey@Pa2^l?@k(gsCwPng;u zi+;1N!pcRivJ0(wT=xO}jSX14VeS7D>+B}3<5%O7 zA%<=%9oU8tQ~akH)_EJLyO~wP)Q|gkmZjB5X2Dzcf0<3z&IPR(rA$OW>t20;TguaN zLtNUcaa~|a>iG0qb~7_2ZvY=3vr6|xa`lYNl?o5HRrF26GtWr#b(UdEe>qGX??I}_sPUQKEMt=@7qGI)j!z};v!jOG9&D=;)lL=}_H z`BLCfq3_jGqjPGrg+{85>5e!cI}#CA`K}vP`Yts@`g)E}ZZ^)5tKJVWFWB>DVE-I? z`Iq^K_t~z=wE)5yy*gp-jKZbQatyEElzqrdZ}YIp0sLhac~H|geO{j24#UyweST%2cvv9Av!2u>5=tea?H3Yo7v9)6IvSz=G}%U#8xUOo?Kd2b{yW?;Ni zW8;_RhH$L~;w@MSQ^YPwFChN4&H){&z!mgvAlb8<9)6xz8%?`=wm>`=oFCs$k+*Tp$S=k>g!5SK7LVVH* zI{1RU+^gELf$%dh4WnYWdaHalq;$O!(tFY}N7}N#TfSof10q=V*)?s!NuW(ez2^>| zjIrKW!N+BqB&kK$_Fd=LR$mNveYzJ@04Jwp2wONveh=|0l%PZP&0@BcI|qnZHdmoK zL!+4X47y~c*GET_5@cOL0&f8DR$M#> ze%-fQ*h~T3+AQd|WRm$cFE7Qf*yh4?z0J!@iMM^fpD>%k6<0($S6{IxEby+`)KA;; z>)tYnTCC#h^VPMs4QN(4+Z*3uai&S}6C3NbeN}tK6uo6}KZR2-RbR)xubSY5HCypK zt`^OI)B3qpO|9lM)DWB#?XAP9nZflZ^~OCED^y}e5Q%`Q?F)Z1C(w1a zgr>m)`Y@-d>u;@mGH+ZnW|tc{(Y8bGm{!yj7{Z6c*6n=UrZZ8&@;gGsN;})%j-{;m zU?%jn7a8=j)zQB8w5~^(SB4h^_gU6}**MjyoH;Rjiz?{MUL^-hX%DsOeS7rT4l%Xz zQ`fCK?0}bqVzQRk-zl~j1^er?p=JMWr2YFxampVdwWFwgg3ILP6J&JXzkl{W)Q7?n zQ17Z#W1)GNY%-=#eb&A&_8nqjlGWz_vO%e7x|hQT`|o!xPzL zHs)9f!%kSxzt~nX-T#lf`rAwVzc&Io6}~_o{4tv3Zw~x#w@l`ZYYISQgtFn}X)@qW zFN}f`+E|%6COAJrKNpbk!2XU@fh{Tcon_xKgqWyCk{Pje@~whKblpIza)=qTDiLM> z!K1lJ31v1A&&6}Rd#gR409JSQ1Vt^m4@Z&Gd!zPk}4y=RqYR4g#lB>_rHN^XJ-Zgd6F5eVecApPC*ygMVHY2LtP-x z0KRphVeO0h-9tAw7c_TVTyC z$y9LII7V$2!F z`nk5^*=6=u`5ET&xDV-#X?crN`aQsa*iMvm=a>)t+s9Y)s{?Ox?_*NWoK(GUbM;`i z`*uZzsKZDO;*8w3((F%7zLVuPj_+-@HulCz5vktI6GvcbX zrGLuw*Hjhz>D(T_YeAS0Yx$ACA-%t&(+nZt@EkNe!|TYnVzm5cBwnwu+?|rLjPa>v zewyP&BcUNvylT>vThF&4`+@!m8PWM)&y~3VaA8%ve*=`}UjbpQH{S%YT!^Cq?c6VIR$nTo+X`KW$87qdgpOYgqi( z02FmFJbJiS=6nnf)v0ao&z^j*)3#eAst%6PJ9uK<-Q4bl_|emU`I7%~dq*7q{3A%*Gu!X-3dU7JOTQB-`L$?GEkL~wBJzyR z9zIj;R*JA^rDs(%je04I_1{!~f@XmVh8+y^G{hG4%$^4b;-uyA+F@~%Ec@HA4-s-( zE)Mx0-XfcWKcZ3I(;s#|rZgdGnn*Rao~)yCFTG>}e)p6H&#~`JRIYP_$(YGQSqjh3 zVsW$wga--x%kdD&5H%>rTy-t(=>lk=Ce%l`T5*FX585+^O74YS&k+ z*+h7W{^)UwxV@@v$|ec0-b}|H6GGpOHzq9}*y%y^`4E9ug$GXUAS`Ixapy z+8MmQ_U-4Ln;%{I;hDjg!QI9tdCshB-JwVZyf}rx5f2xbvCU{tU7?AM4J}j5cn+wkOO8 zs9y4_yiUu;Q&QnNbxhi&W<7$oa!#1ZBA8FvSbqF#J>>Xa z#2=8!5eIB8REHT)A>kw?%>naIQ|Y(p)vV6?-f3G%*m0Xv)d74|gH2U6bxgi{2>fvC zBb;Jwk15kjo%A)klVap@m`e?5y z96eXxtv@x2h=L-EVD5?^alW>(DLoJ;F#XTz^3pZrk}9<&%a@JmOS;g8siV?;Xxo{3W{lj3C;<(O z(ztYZ3;%_Fk5VS30ol3aY1#Q#hYg7!Z>GK(JLM3(t!M*!d}(r#rouqakBE1Un4{^h zS1ju(^LsEisZZb8G;0#T?cO`##hTXYjdzaxF}*n1L9V#xFq8ZrQHogpG7omwT^z`e zVk5Op#xR6^l%~<(4g{)L`l{)I)inw2D@Aa8h4s@BADBcjLJ_gxg|8@%OeQs2MMw{~Kj3pBR#NEuu1Xa9%ql69r z7{Z}4Ig%PG8s%A1{|OP)I{0?yxNk5XFD=yimB!*`q@GVdM#UA5K)U>kT~n<+*FS>mePCzof$zS zIc>PssuZ=SlUi}Kl+rC7>y`(ZCTL~~s%Ir$^@QCkEi1n-v5bF_34Ypz%sdr^W7NP5 zj+%4u#V1CyBQH*33Y(_U>t~9kjfyySk2}k@ihJvdQNfQ*&Kkj4*^1V8CBzLE%S&5n zcIsQP0V%GhM_u&xcG=n)4=WvVCi=gg+3W+6FO~z@IT|ud^nsx-y3{jdTH&o*h*je; zWRSPv1Rs4{y;9AHko&ygs@54;z_{*Jw-uJc5e@_W9%&nhu}cK{rBPDxJX@oERY zpVZXnm{_=l1Ps-5b#;{`#w^XvO|>vI}pk8A3$&Kz&*Q_Ax^ z5h1J84*D=wg>p@2W47^mY7`4)WS;9@PUF)b8;cpMD$35z4jsfM{3?&`TcyNe=D*cS zY-I*;TYFQVKW`sj1s!QCjODZZvT6DLjv4bm)L#leei>O3>kJiSVo}TmfuIH)-L5y> z2a6N zThsjg=hjwWdNE(WpYDDYOz0GFUJ9uv6D|bc*WQ+w%c{7e&W-8mUYGV*f49F+F=nSr z9KQ8Ij_!&?+iBuW5)Q17?d-KWS^I$N^Z!n6Im}Xjxr3_=KjN(1c2dku`)F|`P@OAqxpLCbdS z*l{{+{%Z1@{yX)SR%-D*so8#e9+^_CKmHKALkb^@cUV}UzO#zO0LcCB>kpQlApIKu z;9?U52pBud??>1cy<$N9bXrTkJ!PySpJFxZ>+yfa8|*Ta*CJ)NA8+Zr4QCRhi@GF4 z&dWNAq-Z3}B~C1MC!#$Ao;tcwc}(4zS_usZHjcHv_}HhumtnQG1HXIR7njiY+$~kl ze`=As`&%qTe}j!fh3#rfXjC*nP8C_8t+nBbFl^h}O<-E?aruPCKaSUo=K~b{hTpi_ zh*41A4n#Gg8W$Va@p^gi$@tKf7MC6knt!GtYlZv+fOzQ{_~dPede7s)t?8(c7;r`+y z#llA>uU&l@bMNBp;8l(scaWpz0|QvA%@Ij1%`0oyGr^F(1_W5(?I(R5%hpQRoPzV4 z#;JM}pWxAF3mF{ODxWzMK_txws&6c!Soo$~eG-H_+LqXrj?=z#G!|~OYWp=2FHk|} zOrM}zU+an})N&*xy-QtccW5xj8a};=6hua|Qc-En-md$Hq?hsVl=jb>hbTF&s6%S5 zTQ%dS?mjIW0oP=0FeOgh(c#1;{G3KG;|SYuxi=2!e%iJdOc#|$tmjcj!|2~RbEn*R z`0(L5mI*Z#x+`3MdvlU`Eq_=x3Jgp;yYb4m-3h08_$D~!=jS(f-)UJ+8-dwtUqYnf z!w&uSUpW5HKcAN<=z>5a@@^gzH8A=K)!RbkF7Hhx*6poXXKuGy*Tcp>*xuXTILA>G zm$RA4Yz^0px2COy7Ng|jtDK#TdR{LGClAtYk*-U+VEfNqy2R7yy&bKi#U{uqZKdlK z>Gp@bv|*-Ldwo#dhMrRQh>ufij%2l>+S%E8s$b@-KUk|!`VY%mY@Ywpd$XDr9f$VT zCqwl~e#a2k2QwPAU-p<1h!HV&dy7o`*+@_`N44bGPuYz@YAe)64Z*VzTGv$BS)=7) zM2%#WMeA!ZKBCIKdth4=w?CwzjW-2>Ir?oX=g$(u&cb16wvtNd6DyBcJw9+-liyy_ zG?lCJq2DJfa1|0x0ddhSme1J3ditn3dqXf%kDkcIVumVa!9*`*pGOZQ?N^vUJ04nC zLP#JULu9VnD9g$QoV2eYf%g^ei%ofVJulTI=83g?(SXs?AJd`xuvC=L@YyO!2hs~<@p=H6unh{NrQ|Hdy?zSbTi6S*b}3E#q*V0{RS^S z)T(-DVZ+SDNh@*Hq^@#+kKS^l#xIP{CGsrc7b>&c`lRXuaYdx6ssmr@G1fnJpA;Vm z3dOuh<}lpavOFLi8_2q+)xQB#RaM=3He+{4(EZEU`FD3B=s18RHW9d!LMFG6AE~f& zJfH})23A5|LN?Y)#ZO+} z1DMOev%5QC>1kUqx3C?pR|Eo}_O~lvlfBi$jHau~Uar>o=Y9P3UjiXPg(3cL@4e>2 zqBjPQR3_<;@bXrM?cTFACa#ZmL?9*6H6Ba&cW)nHC!!+$q^qZ=mY^ygAn`5H&%Ode zCOS6%jNE|a!Y|KqJwo!>VY8;MnCg4Im3n+9sjrxJ-?$+uU(>jHut!1%03peCU3{Rp zRC6=u(yzg%Hc1qO@l5ztg28(@&p$1M394rT5_|ZsfG`^U(yn8lvG}5 zt}|GO=u@C5X{om_KVoH^Vi)bl`mmy4rs7gUR;VgyCT3r_I`(=XX`r)x{U9eejNB z)$Xpr-4*V>&#)b64nR$-so5p1j#b=?6Ex;W`4Mwu^D40(I4fL{;DLW-gu=g zv!}`cZ<`73b&r%3~PN^zu|W9$$#Pc43-+Cb_BQ>4zeN1{Qm6_6 z&=%XBrG?kJSx|7rzMve9cQJ?<@!}RK`XrL5!s=dG=Qp=urm`bG0=pvir(dS6bJWkF ze>CC1k65wR)NDrRd z;`Bf3xI={%Re?aUqW2&^hwl0R;y3^KD)Ry$evb7*;f)~2M%)PV<@T$y>`pr~nddGo zllmczb|DWX@$S3WEYLt-jbGo*vu@gow z?R=-~ay_m!w;)ODcXV~%oo12yB!iE>a||kTg6>a;FoTz31RBw_y#zh;PlW}$8-;+P zxbD`)c4@2+rV%d|bqb~kPD~w~YDy9Ss?zy-SYwYeE zgEAe8@`zXj%>?Pv7TetrZMv6kWv`1+2P0Akk+CmMRz;q?ov=u{6C8Hm-YCUweMq(5 zz5BGwWyGIGNq%Es)5sFE2e9n#LV~S&5={2)T>7UG&5wXSHjKDJiRpuvVx|yaHs6hA zyVr`Np^W!$^DWj|{Ty7qbTn^^(Alf1p`k9k!}9+ad-HIp+xHE)l{U0Vc7>F+>`RuC zghI;LsbpVK6w0G@`qRCBd-Sgdp37m|0vX+&S_IbEWB!rIvgw_@*~tm=xJDPotRH3E~cEgKnEegKgQ z$v_=Fw#Qs+KHZg-)6^|;E6^co*Lw5zZFE)D)zntwiYHk<2$Pb3GXon26Rcg z_l!^+T1DD|*2S6~~quIK`;&YXQG0HSk z3Yi2|az+uQgUsfvhDW?`P8iGXOpUcC9U)2@W@alubkebPy#AG+skM<&{7h+55=Yn5 zwYGQ(#r1w{(pBxRn$Hg#A{6@^?moZs;?y4o;ybr$S77AC&X+Ie^uC^3O^hAYrZAno z$eJv#b^Ut!X`+YWdb*Ek&++s6W-d4cb)jE?rp}H0h=}LOqG%q%}ln;2GEZkDhciSYI zSKhk+ICFb>#Xp`xE$gT^{1>stwqai%?R!az318_Kukg3a`=gat)GwJMZ)-x}Q9rS* zk8#=tS(sey3l}aKovfVFtXiUBP8B`jcbYkI^2Auc^V|rdk-*qw{jPprM#%`s=sYN= zDp2aqWR(%N@9RMPzU3v0`95goZ!rxP5Z(fg2ZeQiLU}+b&<)?HWcZu75nn zDStY_`urz^;=OFlWs6Xy9hagHad9&5h4A9PW-UhB)_J1d>-#!Q1op%q0TAN8YlV=Xn`6r za>$O&>rGiPRGY7DDZgdz#4;UG^ruy08EfQqygLrEXgy11;^YpPm$fN-+DbxGKW|q( z$gd8nt*sNEE}v-kV7TK@PA3}QCdc%qPY@nwO%m5HWzfxrl|`_gIG&p2Yv)JXb>HQu98{uFsk%^ha3!C zuPHUV+38=tR6Om-wtiuQaQfjtks4ddK1fbKSrgt;vY_~`fhGgTl=_VbZmx~{WqMB) z87D)Ye?6#RbF0qO;Hk(t1p{B&xU}5}KMXPf!9SpR+Rq1K>ezHh8ro!2P{e^V+^*+B z*AVt%Dzs+PdeE{;9b4@;!FX9l)Bi%aipYVJrxa-mZ-7whcUz93fdi{Zb&Kbq4D-9h7F<<4{#~WDBc=8RATP3G!-3Vwn;B0wa1@&r>yB-q#|Dy$_v$)^Hndg)M=`P;9LSY+;=dF zo41gH>;~hK*oBkKQ>^ZujGr2+w^rSpmyP-sNhyj};>1@Zf3Oj7*F;ei->rlI`Lpb+ z`>TMymTy*yF`R7AE6Leki9|n~cj~%{oj#1GX)A4Q>@U_VuC8}DH+<+L0Ymq%T;fy3 z(Txy4k$1Jh4`AveiU#*NK6tzz{eXqm@X&{gRUGl?;aQqb6peGno1Qn*R&Lz*il`+q zy`cQ)Sb7HJBw9M8QJ$@B?p)n7vDtSLFPsw4mc%C~CJxU`V_uv9UB`)r@b{sjmAA|T zH?3IZo@D-Tsb^@9&oDAa)zH}ibDH-FmWyR0tQFCcMV2uO0Tn0qh?-U0>r;|9SZDd5 zKYRAi)F)I{K=YY^PkE&}V~}m7pMzZ_R3c$&Lap}9BUMV&EC)y_D;+j)H!cpG(ven8 zs&tlOa7-biTCZM5e^*02-YnuTdNiolJTsu-yV`OJF6e-CZIC5|yE*+R7#(`7#O-jO z;t>+)q0Qb0kNF-N7%R@(IRx|Ko{dfBy>dmQeMg?`)`2!m38GwybbSUdiO{)x9VYW8 zL=QPDXYM`i3S24f71ZaJ=)jI=Bi+ohA#$ZdDMjMV7@R%a_o~8{&-|is;q_8^+>{mz zyZ*;DC1PCQD^5{G1{PSj+`@rPyT03lD6dGQ#`&<8W;|`-Nn|I~_3k^lj<-`x9m`7@^qa^=+rV3z0T9 zGi6OV+pq}uBTFF$9=~H>kNh)fSCIhyQ^tIuJq*-*|EbwZwowj){oniK#4)l6ZOoKH zc>)DO@mO!2AHtdvyDQ$ixn&sVUycSoX5JZ!HqX$~2ag_-JQ56CHJh}=s`+$)Z0$ET z5@&Zp>eCv!JISJtsxL?>2j^P2-uExzTh)mh)!`Cog>IT_8Zbks4}AX!^ie4WDW!9D#xYnJq)7;aQZC4|^rueQh&z6}*cIhm;5krR zmyUf7|J+zZZ4m0q-P~SzR&i4e&%Ik9O68tb`^d!Lhu&FU;i6U! zG+>mEyPAH0Fl%#4ni_jG=XOLP=U~|~qO-ay40{{dEz$voXYYT)B&-BV+Ib{Hc-^qP zO#yQ8fT%t{dvY6j$xsF0563A^_sfJb-(&lE0z|s|`2w5-FI{4s-3YfUc<$F~MT#9! zx)-5se@Wz|nMG2r0&hk}W+OGjY6|^XtLrMwLVHf)5c@ARz~9dE|GUvogNJTNCkny8 zz6>z|kz#uqCg%Gh5Z>C^QjMrt3`5#0F|mpNFz;7!A#+vFd%!yg$EZUJVJ}~<1V(?s z1zAG73t*50q+myji$P{)yWlF2Ix74h2rXQGblSd$`e_S3SJ+%zh<@m@ycF zmSF}AVV4?&Fs(ya*gmdP&I-MKtQ6#Q_BmqoBl4@Hr2HY9&ZvhFgRZxg2=r=F^@8cg zD@6KDpSr8~3#n5l)H$r6JzFaaCWt{o$4dr6e@jyQ&CQppB0>WF8GwS{S3dfwe>T5tyAk1$UQ#y@sgORo`T-i#iZvW5KdfUsz+*2+E6W zX!BQdmuLH6UVr-fEAzy!K@A=f^*>*37EOjA{JE4ZR=)^;TugI}FaH+sXku7VoOZdh zy5rC(SLM0cXWQno|4> z;n_;?`e@XSmCr1Cn@`)?zQ=mi@x>B+J0F_FE$LqFFWIcmUlw^K78@&S&gVO-;#9VD zXUBqF{{?)mgj3!#`b<~XliHl;CoCKWAmV@SdcwFYIiQ;(!?ZfI#ot zcV;M0WPkm`hd{Pp+-t!!UOA{n*tq0Y^!xW&UZm0JxHtiIPLB_belEe&DA$Rb@sf7Y z!WK2Az?9eV{`cGOfFPY5WhTRFvgw^Zo2qKJGLSdj-d@2JJ*eB4k#7BFa&q!xByWW5 z^-2%InVHq1$IT}}A0hSI_wVu4wSWu|>2FxKeLM$jB;S{mNNy4=X!aegeo$A)h z$Xmvvjo5xlN3ue@A25CtcMAlfx{`Oc)(8Ru$?nlN*h#JsB(^V4np~OdcFaSWU~{_> zn>zwGMjr7!Lfyos(o>W55WT(kn>#wB;eM+#Cl(E>bqM_k88Q%mX2n`fv~OiuwkOKy zfGnJ6qA@bA7&vQK9-|g%jO{%74#rO%p zn{7z8#p$v$>=veaqO5p)`~=W9)icIlXkM^VPz_X~`wcbV*Ekp4Af&P=6GlpgSi|$| z_$`Ds5~Bth7E<1IHJguu{?7d1J!=HL@jyzd80$@40~6Hx0@K;=NM|Q*y%@nNhdyt} z+?^}evrNhyOB)N$oIihlstAUVQO)ivomCDL&0{BbFgH=J4%< zLF5lH&7;+RS1CpR)?aw4^=W)x-FMj4{c44fo?)FC@g*!YB&XvK}djd6O1vdz3<$tXuDhKm+HQ!&e3>fA<&{wW?mXxn(@ZkT~s zYCsH!%ZPv+2+h|n2A(TqMd?}7$CE3Y8M^n7ot>Vls-1~gn)pNyAiMra;?w2=iNrGVA zcJDs@+&1V^xxKbY@wq9A-hM(FzuHGzjzc%CV3eWG3S0BVRzOvnYm2?-?V9n2`3=9! zEA1AlXgM#gb0Q}#Gq=n#9&Zwrdo_S3TIn70hx9=niqgY6i~ZgeI#=_OOBRyP!%+en z?)G_dsfvV9HP{grYKa;X!;mpw>4$H7XB*CbIT=T!1Pm4D-_kTV(-Hyg*VtU3UWEgB=29D9P$pRE%Rfdap$ebEp~?Vy;>+E@1&<+C*Ao4 z6nJptERZ&>Gj7gh=E7@pig#NE;4v$Yzia7y9DX8Nb2FN9Y;k~8eKHRnK|HqOn>y^5 zS7~bChWGL@BGF1TZs)c3^bHPBlzuoP$f}YD*cstJHugy~FxtnAJ|MP=p-(diIbeT) z@agA{6-;A0kR=t zP7dwgfl96Em-Ab9B(-x9jP*ALEF;(0+uS{SbL((HwCc^#FX-CLb+cmYa3d@rP4}=7 z3LCj3glcxEq@XfsY-{mohD3*~Y7KRL_!GXT!os~vZJB^Tzm?x|NV|M=t$JMs6EeC! z2Sc>N(xpQx2)K?18bgGVOl=3r<74eb5LQ!O#p z2j^9&&yw%KI2>vRj+79n$IfhHa~mNu1{g95M-}a~WB=|+KsH%CQ1~HM%SypkG-Q5{ zAID_{Q6P+`i42>oLM==!JDh%e@)a?1o~@oK7VCpWs#PbhbJ=y&`z%8^PlfS76*QFj z)z_Ux-${`%vEBMRxOyF>++z1ls>++!Pl6u4j>y_OHkpCC)u856v{2|%wBpBGOBe}L zl{f3N=+<7Pmc91oJ&+`Ao;mT-r)YU$_U3W#IC9r7>)>}N$y1d>2YyVct6ny=WPgdZ$0Qey%sL!xN{H->bt}) ze9rO-D;`2PFB&lS&ChJtZ=Bx6Ddt5+Ee8~~g-fLN<*BNNEf;6cI=ywfzkqG2)VF)t z@ahB=*%y)9wzFXY4g1)myJ%ilHd|Ls9&;E#8qS28r)od+Dz_7ll3a70-ToKiHRz^ZcCnBVCjkLF#U`~XOWkZx01-RVNtyoT+ENJ9u7!`dZ$yT~9Pc{R$VQNqAKzM}_LTw2o=xnV>?^l6vAC~qJLHf6<+!SO(q$9JH zNK0My)cTXeI${=iqLK0j^{)Sf{@?uBxtC43{2jw6}MqdWY%9~b(hZH>Lg&q3yj>u~R*vzo*A#p}A zgj{D4v>Ct3eOXseucytdkyF|HOj|Oq-}-%#V+2KWOQSL+m(MG3ds%tKYTyp!7Lmod zvz6+%y?(T$Fthw(r)cRf*nrB%Bi|Y-1rIcUf9OJHC8^qk-Q)O(vQ>suc{YD&dX#o$sJDXEZ6YzBLn6*hyi?~o!4PMBM<_%dN8Vg&9_y{|1he!wp@M8bnhox}A7=dnJ5TZlBV!=qpkG&6mEWGqcICSGeh zBk}_p@34omJ`81{>fI8m@}G(^XaN@_rqRH1)>s6rTMq9R(SGN_C(6m4a zUBoabX*M^n!FKG?D)?TW8oH10r`0wi_C9`LJqp^GaOK9O{~9`>5~< zk@H#YpVp%#D~o}Nwh2nY&1zU(Xntol+rujo3RqEJ6@mtMzOR&Nw1#4r7%9C@sx4E6$$Hb8T(TMxLoEN0a4I%KkY4`}DtH1C#^F$n8a#aCOXi?!#fehsWmJ)bq`^79uEO-jD&uDaX3BA78bBrN8gu@S;>f0 znma*3%Da8i)B8k(xQGG(u2DLEYJG$j3!QK?%pw6m5o@uW_;Rh26^d+SZ3OF^ohGC zqTLz~F{MOXlqM3KLfoTf9+TsI9Q~JAj3{wXD}rC^V{-JqOB?p^ZQu5|>0&7rSj1s2 z{h(9yUIm`bb=0Y%nPp7-@nXp3Q7MPwJikXNLKppKT4i=K?-%vmM9ZOLPq-~gOPj9Z zre#>PnjiIVN=>4sg9klnrK$?I9t)9KE(|Cb{tZI^*E#>UUxSUo5Hmw93*$JhBi?L( zTSv8A&k07+ffOjo5ZO&-AX3iNw0uRsGY?484>;3s4b zTLWo`yL-toj;ptSo;s;GUHh+&^`3pB`vTt<`FedJuWjUUM7lYrN?|P(zmWE@Zr2u1 z^L~lED~E_a^Pij~|M86ch|@tCI9^`S=rtX#*d}s&N-XG0pDmjb@y8(br3Y5NJS17F z*FlP5p*>+L5Z5x4kJ&Bura*9l*?u>J4Ix58aDiJ+s9{d89}f-skTUXTN^5wcxJ&|h1!OJjIc$zzvL$234*8MT<=u0t?I^3W>rc!?XQYhvt=6uBBt%8! z)Cht(Xw4WtU4>2)?|JHxLqba7TznULzq~i~!0PYK&b;PFtu7jZA!_VHU~mtT4Z|mT zdt*fkNMz!Y8{#hbzFJ9cv>hARR6v8)3f?(&>H)sDgF+6*u6luHExHs!#DlJWy?LK~ z&(3wK=c}7V530`?VaBf#Lk>|JG8;AK%ALDc(k%J%QCnInS_bnU0yeWQ7(BlH!RTSX zcdzjJ+=O2kBDg^!Dc_|?sAM;eErs$T;pXtE!eA}0jy64c+Sa^Ue1I-?_ z)@MT0>YCd_N#mdv3|0B8%o9BdqY`38c_TSQ?xAUYq(!zL^PPq7tD$v23zGH0irqgZiBpELb z3UB>K%n9}ILK(Pmp{ZclB6x^Sur1ZFN?K2(fR;IW=j5rL?)kvJ7e^=Sf^htlb8(*s z)>*j+?mu7k%!hU=FHQDXZFcj6nV3nxikpXVI5Vz|{jSF0Ry_?2kMy1T3L(NKf&jSk zZxk=xo-V_`34!TEOwgR_teV-k`E?LbvWHPniVQ^_CBXa%h zwfZlX4Phr3W*At-zvVF|e4L>j#X|B;XI${K5AHty8f;uJ*W9-RxMpVI;o%IDVHE+LpRT1x zK7fOEWjfYzKhk-mauC(h(lS3)X}ZXGlT5;)yL;?5`t6UN5iU5x-)o&#)xXYHZ~Jg# zaXhBuvreiKR{*g3{VB7p?7jHlO=#%K3<3I`^#)e3)|a+M*2WQ_xSHxsRAx*v{IiAb z@f`iBq+MPOK&pE4j^$0*di%@|6s|`xH&{#>OAXk^Sp-%fo7@x+^?J;dwU4cfKj@y- zuZ#{DZhbsuwh-jdr2J_Y$s2xVT2QL#`9hg-;b8QiO9aQ|GTdu_~_CsEGEeAj{s_2FF?)|Ds-Az zBo$6Hw61Ua8&wXhnDn5|#soH|`i3ZOEiXUmdYV5H#>CVfFLx&-++AP5ZbW|c5Y1cw zqqO;&Ly{std{`j)f>DHhF}qA&oY~8lo^gW`pPTPmMif7SSVJcaBJo| zK&!)D=A@?S8Q`3_K+X--a0oYH^XUn5s%l*INU}=J!>i+8j)VU&?-KeA?HHzOFTV1b)c zL97m?_{t>r1n;M>lG!pt%*i326*Dgc5Rv_$PtJAaIYoX3_$bcfEUB9cOW#gWQ(%Qo zz5VB%`rKqY(D|xpY{hij(r%RHMegR*a~=D1qvW-uS9bD8{!5mXx4O2S^sYi-r)f#hh=-g6YSqds zhUmbAEiWNL>3C64BiAL0U8vm6X0hQZmAhkNaf@7$9{*?={4JIEM=v2K9!v}fmP@&B zczxYOzgN783@b^GL9BOa(nG7)qeX|DFe&1ji`25I)#8rAGP{>!)ik^UgUjUY4*m=$z|8B^BDbu@{aNMgOG+>^wu};#kA>o19m0cVdX5ak znJFyPDwUgO`(ZKBNd8hgp)%?OS2M0uO`46H=QbD;(oU98T)4}#2fhf4O!~oP%~H)> zMx{Dm0{Kx$336GhKaaz%zvj8(i@;A#b7b5QLHNHx`aeH)o;`3rtiXY!{Nq9~wDQNH zw(^=qdr|~Z9H2nA{*K4;9n2S>BgvDAKhAxJ#kH;*-7|{`L{=@Nonhs4N|87(=s&96 zlbg@>7JDQ%M&BBlpK&fVTRT}0xW4G8JcINp(){~bKI?COI_}v!SHo5kFkjd>nH6Wg z%xZ*C=VqHv2Eb6**(+b?i+eV{Rt-tod^dm}qx9a>X_o3YJ>`DOhDXk}QPWdiwXtsB z*O+4yg(mfd<9%=6zMa3cHZ$%k);@atL7mYF2x`VJ9Ue}$RkecP$nv#TeVf)K`@S9R z9A$}$iV9CDD?D|%UwJ^9VJpR>&&b+aRu^62hNm0SpCB3!4)`#DVzI{PpdtNkF*@&x zDF1onjkLK%!u1}oytkg+=RnMQ-2ASl zrPZ?x>?{~eoCs9xT{m+I!pK;|n<{ZMbA_2o9M5=fqS4}N*8a-5cZ`%DpJ>&?j&2FQ ze!W)u^zOLj|0wwW`-d00wd7O#&v8^;Vqm_w?FWjER*;g+P3lRVPU!Dn8-EKaEL<-l zH&3@Ei7K}zj8_{8h8-sr>nI5unEE|mI#=TDh7LT-CsLifxu~RqenUgDq{aY9nI4y? zbQF(?$(%gh#gmUUF16=Z^dlES6jP+0QpPjAu9oV1Oc7-)VsNx2>y_t1lyeA)F#9rq zf8W+;g1S?Cw*drtS@42Dyj#EDhtd4!Ixj7#i>Z~Kq%MguDQ$l$f5qfmhphTEp2CQj zgji>#DyHrzawz8N!l|?5g~nerb;d7tR%*q%E8tHWZMO6*%(BvOJ`*SP?+N%5JEx4vbBd+$=32(2;NMR??8;#-*%S9Ql8yw@RB4qkQ{ zQ}J2)&Z356Pr70mVkF`YlHz61u^1NUwa?5>4XhM4uQCK+C;#`2^D;Io`FrMn;GuGt zn%B7AE%*UQ2y_t-H?VAnb153rF|hFg=M6nmQ(-paByp>^74y)lImezW@KX0#J%_?( zMFmpV_T~!AJwOPOw>XoXX5o}34kP&yU9YT_7x`~4Yhp@q8`oc1wKD3v%lImg46^7I za=g=&v`bM8))qIObdx8U;J3{wy?xz|!xS%^|Ik2SCm+2zVqkSp*h63@{n zFH)>ciYvN5pi7NqQ?An0xf?9+tOkdB7ft-lb03e$-|kbCHaKGAnA_F4#!4BH@(azY zwDAp}{zA{s*`*;4Jt(DoB2}>x@Ws4#W=nCa_Wi|~F23yxp;BDGFvI`5 zhX3}{7yu#r`}xMskQNd708x0pcdOsCvGG0bJp-Yy<;sNihchDn(3n8C9hTI9d6`v* zWRz{csLzHnxv4oj)r#)be*FPMv}gG3KxjJXXFGGwt!$6?Fh)Da+cOJfsg@~D zYl&h2uvZQ6g|C8qgkYM+g_fSMn~?ytZcRcZ^; zp&81&<5LD>BcHa9SEeUG$MLd|r_yp0F97MbzbA!<(BZr%7%CPj37G)n=aT4kdFbm~ z*;tyJ|094r;@J=28xwQTg+!B1*{|Ut4RNd3QX~{N%60vD#~q{`;$Bc>AKXMBMqk#v z;6ZTvyO^n+lY8)n$SYt)J6YUT6x&|y(TPdvQgBa+jfKo!*^!?iz zqIEv@Pw-e;k)}@?`SiQLc^ww@VlSA~)yXYci2v#4NVz0e*ysGV93s)|?4O-}0Lv8+S0>NHP*N0NLj zZ#OCc;G!Dn1bSY(IxHr$+ z`$J`5BoNjhbPF{tufPWujy-1ZCH!>fm zxTa(>vWcRV{3A~zj_uUk@W8*1N*1c7xtI*-Q;_+V1`pFJ%3(Q8L%kPMtA(C6G&Hn; z<=OwKU(eXsK4ca6>+z#E+oAr`-9=Vfn=7d_V1c)bxsb(-OPc)qcv2zLSd}l*@Qa|? zZ|F9N<7OM8f<}bZK*7VGdF!xqmAgcjSKHt}uCi0GjH4mz*RZBDI5axog}DhZ;#yMD zAM2a~8TCc6s>NqU*F3g2mb$C`ytX!6%$qto*2~o;Y&!BJH()U-aOo$Lw;b+Xf7o}N zJXG`4*L(vIY1+xxsR4(dN-^8WLR42qANs;9tT4t)fcntQbh@@*a8h|^BLge-7~`wN zFmJ;Ab**)8lCnC$P1Tw@m>Ks2tmP%ZTI?0F&p4T|rQyb{XABzOzJ8nMxlwinU^i?M zqD?&l#hgD(obMBj)j(&Z43%l(A z=>5-qv95v8)GeKm+z73g!6KTK}=YxCA&&wKIJ>O0H7ojNJWJ`$64~vKg z3u8=wmU+j3%$se|XdK7JVVt4Lc(!f?%m;mpWMgod;bGV#Mv;@`uIb#9t7MzT-08CL ztitWOcuvThAgWc`=Aq`N$No*`0N_c4V75M5UntXiO?p!gtc2GATZ@~A#7h63t9Py^_>w?u z6|;IH2Zz4fNmUdXjVLr*r~y$-)Q!#*g$NJ?(Ke#eMk zlV(0Lv9tgNhnjZHQIG2C8VFrf*kL#hB!_$6yV>z$gmVfXg(mI$D8ceeo$sWH4v%9Pd+halurspa+ zqnH9H1rQ%lDqk(dx~uJv4Wjg<&evz(jz4dF39IliH_Za>ofm8kOLps!>wA>p-KSED zm7KZi{6n_OZ*Ii8J%QgQ$u*?fsD2bo_9j(x9Vree?_WPCS^L#8Qt<+?vu3MN+?;%` zc)gs(!oSd~Oc0?!>ZxZ^7B+4kMg@TE{>gN_jW3jOkaDGwgEv{;W0_S4Mx{H-yT#FY zNZJsg*2hBs;!z8mt{H9B|3qVuFF1WlflxzA`W0ZI@F*#$-@aB_S4)AvF~5&6k_}*5 zjfDVpjcl#0_!$3cvI!vz=4BMx5JW1E#=g69x0|%~o)1sr&6-dP$RsY-Hu&|i3^uBN ze@TkV9C-gnT6@7aFM!$acO#cS|3>PS=jqcUh7Wt;uX2l}Z^l@)#`@eg5yHvCh`#fA zjWMwN$`++WIxndxfAF-LKq7Akd4r9DL0(aTD>X527q74O-xzUj1>(63ncxW<3EU#?6=E|9_)0WFEP)+e zlAWa-dg;<7ad612u&o6$?XnRWIqk_^2c?io>2Tb-%tL})A!a2O(B&13;%lBg<~nXlZ)wh@R=_PX(Wb(fl$^rYpIXKJNk zXKuZLxlb0R6M0y2>)4qK?0_;W5?FIFP5NG*^F~==NFX-nahGSJAa#;si6!R_d#Y2& z6Jmt<(an31L6c~8uXm-q@qCCgh_SS?bNJfO|HSRMoU7>#?oeS^Hgc~9Clt9ZQfurTh zFEQcx=Y4)hm#w~sMz(gStytl-ae~QdNuIa$iuk5DFUYZ22#MV5zAonuZvag0*k{j( zHw~}ogNCgAi=iQ`eN~98_vZ4{x*a_zzW^65t$H--C;+?DubD6i7!xgMlsM$f%2m5= zzUJmDf|uooz@`=fIPr3~mRP(AGT{py4F~E|?Br=hfI^+q$n~lml z+^KhQSPDMbxV3QO?bHEs=4pA2SM*+sedgajzO#WX{e!U{fci_`B+hYIcG~ev% zl~sI-U%vX~;R-}Yl@YcL<9%G z!a3yhR!JW}_pVCaf+Y|fqHCmIR@!YCnBaw&Bm_q)Zt z`^$(zweu!7HT+4Z-?Tdmz3aNWer;IUZF4T99V!~T;1w=Two4t=36lzFg)nR0zaGZ7 zt`i56@T-E1&;Z*K+8r(SyQ}+=iRgs>fgvUf-|P13o(ak~TrSyzAoHT9B=;(d&36kp5Bmw%i(UFt zC+6}eJo?Q9(A&$QwrhaNy*O5vK5hkv%#BDMqkKIo_VHZ3cJhyy6V+RG9^;@hyK&$Z zG8O2Rj*$RGff(?ydIwNx%szvtZ)dllTj`}{j4Ced%+(jd=i;VB%_^H#XM3gqe`pzH zA!Bc-r}_bb-l??J?@Xn$cacuCxqHunjDHQnoaH=~egaptyD0S+wO0CMG0U>IM5e-jFGO92te!7ZcNj5z45Iw4i)fqC3q&ZDCj(GDibn5UTaN`2!{sg|>2J5P+Znsm|w^3KG) z=i4PG5IdTr8nl)UQO{`O_yFYg$U{nKM;->~B@Db?pb=op{1ak!5_Cw|Ej9*^of+O= z@ZUrK|L>=BCtl004_LMbQ7x*xU2oAnK7L^C`dFXBLiCx2SNsm$5V+w51bW;54&&6> ze`gF|H9r|}8@#ObahvYzsS?cmn#?Zet{pV|Z3e&Ky`jb3JG*^mgvKQC+9X@x@MUil zZ^Nz8)SVL#ptjN&>d2RMm7Qk=`Hp!)Jj2BXVsb0$+>W)kA>eSbOL* z;O->JUUAGe)79kxZ!Vf&EB52~B!FhMQk4QSi}qxCT-zd(;*5%|o9D2wtbzjFpA!@9 zmmgqK6th%cF+$2dVZcbX^#eUzqX?J=;!^be=yvPQ9R^=hV6no?9Peg<@`QhgiHUzW znU^O#J6wj)dHHiF1$Hj7A6-9S65E&NgRmfM-+fG6;%Y zEA&~hUa|QCsPW=}FY&2vJR!v(h7KAB&dH6iS=NPn9r=wIg8g5cn z3bMtOZ(g{#JBiY*D5%bdEUme9T!LPs#|jwjddfUSKlNa6@0+)gON*;4XVzzqr^kRg z{JaLQLPCa})rda%HUire-_P^`P!~5iEisfwMgcTo4fa%#FP5l;fRdjJpAA@iRwK3F z#LT0^qXusC<`A;&E_+jZRj`B~1+ex-AXrmyMehLA8vYS8+^{+e9M_Qb~fYdr6TuMhY=e|qS=>|Qli zXV;RUzPko6kACz}pVJ5&j>1CVn%6p?y*N#1Sv0k~CQ^lctrh6U!o3HU9K2b9u>F4xE$z zm(%(Gp6}nD{+LOyAB_y;N(oz}0*;IDNYG9J&)L5hR(Pt?h61QM! z!t$&nBgUdX-$1bAOy=KM^tdKnN&JixaKv$V@GD3g{UpX#E0`C0nXZ^j_*USQSjBL)i+=FY9K zo`*(Du5u-h5lgu};mT4n2_aEjteE@c9RGlkV$vzmXkwgbeYhi5ThO_y?5rKTpg@&^DbQH&7+I+U=D%D$7d`2u8h;@c_5;9Z1LDx#=W>Bw%*Ht68ps~8xWU@ zk8vwijgr-e!gu*nZHY1n^Hxm_4KPn0h%zTje@g`8mycfrynp{5UW=Ihgr4E)mJXPn zRLEr`NZd+Q3m*L;L_0}gSQV)C{`HD}17VDbfnB*1on`Uw82;A--^W+rzVwLIb@Y}| zsXu=mZcn^K80|pG^|>HG*7jur6e*V8tW11Dq5iKv7p;U_Mc^PO^Wray{t>5+XG{THWKD6Vz;7?|+4%z7 z@qMhm6xJfQe$l~7CzQJ_PW0l+1Kk7u=Q=YrPRYHiwKFzO5c_=MN`Rz8U*2d6r-I(K zDp89UfLz=H>JlsB5HdO;LGU>pL-ejZOvo`-y*THu>2M{7)%bfr#*}jG$h0+(fRv+e z_&0q0KUS_k@bKu5OIZD_?7qtJ1AzMQc&Do3Nyp-eq6b}%o}sU896Q_XwU132%;6zO z@{VJQ)HOs9b3oyy#V0UhC2rMnn#Oc`AMZ@@O__l88Hv~bn*07A*Ku5>c9&BSV56b) zyY+DG_-;tJc2;UNBuT-hbd}rX?Ulk97(^>|wE8+Uy6w>JF5sCzxXN1W=M}+m)mwLU zu>MaCB|wC-{a+CZXaxxe8{q;hk15)W`h%kFaG&H^mcPF3`oYz~bj;b2HXH31qA^y# z(H#Emo$RWWO8V0M=KXxV#v{maVXzi&xXB7P1Y>c|hNMGOu6HEscDpfAVeZ{;t?l1O zaqO}Q+kNAIeuI9y>ry65{nvHLlYSsw&@BsTY_QqIi?mng)d{W~^4W5%N!f`3Sw-`Z z2f=D87n}0jh3>CF6YP6vMnWxBgc8}9&tqq!GWc~TEjf`@abH!f|;?v%v zKx3=l`}4(MCCgQ#KYte7dj)qWjWOoGDslwV3Awn_M(I%Nr_9O`^Rc2v!dhp<#6L?o z0inuEm*l#>xGL`k0+s#Doi?wPwA_1aLX|8BejoCGUFX{ZV5b<6tdX6I8W%E|a}}WkC)3UwE$^56%G}P?d^qI=0}Ei3i~S zxz`O1l~hj3u`Zi8O85T!0)U`jKds?+LWDP>RN4=ZrLV!x+g1mgX%gq{caF6gu`n|? zf1?Eos{w6hpH|3M^o3)jzaD_p5!)Gq-3z#q!XIICYN=qDCkmf}kPGnEmF za&pV5Or!6CrqW=ATU=P`WsUXuLG~Gb@xR26R9+vQDZZ9^f|A#16QZQB%KGP5BfEPw z#nybkUd^YvLz9@z&vkiNgQII@@el`pngyw8-D#MQR=ao8W&40pg*_7SSx5P=xAIW* z6XrmG^o+#yRAx%sr*7!Spq$%(ZPj15!apAV-UDCtcs^S;Mg(=o1A3jZFiUObZ#HMh z`A%V>R>QfJk-FR}1CM44@{y9M#t6;ZkBay_=Z18ZQcfA$EUaF7;?i~1gj@Tb&dSBc zg8^6@0OfqlGpxA{*ngR7t+7Q`1nEl`W%=O7rBOG-*|6DK3xA2G9dZkfjy@T`)b3UE zVEmCKKiK)vIv2uN0;~)EFtQ&yV9q1Gs49N`H_3uZ`4QXElJu{`^HQ(v(FNpCjcAXB zv(Ruk{Dn`Dlti)%R8az5x`6&DueLrp+0=CO=3kL_c%3M|T2j}Y?ImWS!1&iwJ8z-_ z+wO>$vC!0vxCO+gZg2f`GphW?x+h{jrda;hm-xGf&*OeDXM?>ml|kY+dWequ>JS@* zqzLjD!YQ}mnosk?FBiq)CbMYnlA@>(?8ga{K77d;2%G*@wc%5H?o0T;=$SmrTp_5>g~pes)X1 zoco?4I7a7lO1Gv%PckzT)5b(LVA#NF$xmGmBcuo0~6RSm22#i zjofa0qN3WM*JnR?s{KD-eeFTdw21i1Fv+U!tD2snAs;|xKFs#iM8!+gc;2Uq9Dc=r z`8Q|mZ(qSrU_%%9QusH%87e$H``9!+5OjItSRyUZ%vXt`r!sxq`FYMA{k=Gqe4zI} zm5w;4suNe2&G0T6<;jXzEs>ntu%^?fP&H;A|Nf&Ez&VMBnKiON+_vxcSwz9C`!ZV z0TWPS#2B3$gZG-}3D42zd42xz0|VT>`@XK<^^0#*X`vDEY)-SCTfH}(f8k5?L+SSK za_%deU(OjOX*COTYh=-;dh*7v{_yKDKl*x4s(N<)@=uiY$jpx8GL4#sN!xSo+l4@G zpEEFtiO#kGMR?>dI*x086~Q-46C73~Utw5SRlsooxqJ7nV|$}SAGGn0w$_}2fiI<| zC+2&K63Zd|I;Catcsr6mk52oKB%WJ~(O_EFHiSi)ZZFa1!|Un@rJw@PF1PmsS&R1U z$lrw!fAtKAuaVJ#bRe+X{DZ>Ji_P!F`5w7OYWq+{OIAox!)XHE(lx~dpr zO-*n1eVwl)`|fLdFAm=YX~k73DXB;HbFWpp+$I`?M*y`%k4pv#16;E=Kz4xpp<@?nOl-tTS} zjltv=rW05ctPL=hL*H`@dMkHE*8Uv3zh{nr`^QiG_`_Ac*P|i7s66gR`I4&C2SKqN z1Fqfg-;)~1Q(kxw6u%7Pvt*Rfop{=tvytE768O}beb+kB$6q$XuVfQ5M9q;X^XO5j zNu|XV?XW!fJQWqOR+4RNi#VOk=nzVC0u;czmUn?A9ruw#>66R7>5udt5wi=&m9G+Q zh>l1-nTiQ;&W5oz22l9<@%$=V{qc7G;|ZJr6%^l9F^!AA_#iu)ja1W{qJgA3Y^(_v ze7Y`nujB%y4g;fAr3h4CRSc|n$wH7g6OI1lS( z4?D(fo`*2Y^akD|j#|yX@;La9HNQCH5b^@n)vNWCxwvTcEcHj?M1Jpp`PXiHaUX0i>B;Q&xL<59 z-{Tf3l^=7^x&j4(>LU638fwERv={D8I?0McTf^70ZGvtJGgx(Owffnjymu1_Px<*8 ziZ~(fmy5ekB4|;`*7^?P8dSb8_RB(MjY#()8*96!U%0^iJBt0{tNWz@f$(78S^F1R zUvO`(q5Iq!MyNW#&zPL9+eW2@sdrJid+5e~RJvJB2OAp1bqS=3I1zsj{n zF0DIeInS)1HzVMWVbfhnESUj8HuZ552J2&+X*%kagZ9-my4Am%D*p3=jeu7cUx?_h zy|Q9_t{7XIIZ*A%(Im@n9>vG604ogkyR-6;Djp|cNqv5+N zM7H1wsy)ZOhkhzumD4R)$EO|AEPC)!jWMLlv+<7&XcxOwL&VnLG5@ZHr++72|LfWQ zdfoZ05Ute5lNri>@rU;wq~xs>Tbn*c^41h1S%rsE)P2`CcxGwBt;2-s5>8C#)&$t= z``0h|8O)Y=FD*e9H3j~+CI5%t1LFI)C9_-qBB9v3^;NqU$un$yYRRYDXYpjS20uxo z8PLJUT&ye1<%8{AQu{zAPLAs2<9xwsn%y!A}=bx7L70Zj8`0gKT_>Uvo8E+q4RSH{|`~0vB&;M z8cp{j?O*v}`HCH>3(%rdK_-7J+B$=U)K>z{n>zH#rcpdu1yf@BdS?_hfdTUWE3o6$bI0b5V0fQkFzo%oM zMPjNJvO$E@vHr5JH^RQSQi5$3TXPCuzXZ|4=hmS;V!Z#?D>3nVL9u`1diDbh40~OK zN%id6vrM~OCcqu*E0f>kWK%?PrJ*Z!_y#~rc@40Q{WYjlNQoaV94DzZn;Sfdi=*!b zep%92tMYiwbhq6+pAHeMZqf=#)<&;yXjCKXLuc#QmbdGgss7hXyzlqoc)EYLi229O5T8DNUvH(-c(w@W zS@?T;I{HL?lsx(dPhQG-*e z4Bni?xRjV(9;(L`zp(C6Uv>*LPQ36ue+hwMJR~+xKkY*AN$TVBWKf@r* zkj#t~bdDH;pgL*>+3r1sDg4j{_FFmKV>s#p)HXUO_`WdKdu--8_5}wA_Z8_2P2r?2 z>6<+Fi{&t6Cr-c>5(NkOi`LB8pNEJ?F|FoFEX7rhlKPH$<_R$BQq{P?kq0f&#zQh@!DUU6%WV&ODtJ#E*;f9V+sZe&ojBQI4GvRs$FwH?s? z(WSdP*6DUd;VC_L<+*}@cYh5y|L;l?w?A_A2GG`eZ9!%gt;7qSQ{yEPbTTtKnWJGY zQuK$1Wnn=QO3f1ikS!A9?z43BqZJr(4me;Oa{BB&bqBB~DK@gQ0Dp1)k8rd{=jr!c z53G|Y2Hm(;Feh;fU##Kt^%WFk$OcWFUz}e5?J_}o<5M8!)4QLYz@Nj!%gnl_v6T}s zhYpiIRVW9AAW+Y~0fduZgLT~oKup*8_>g4lW@ct`>*y}iuZ@13Pd<^&_h*Nz9385N z)U~btOm-Hqow1KaB^xrmntpf33y=XMiVGA4IV8wU!9}U-Er&|ABod5cY zQSV4vlWAJ#&&`pKk|_>T&1(qgem~PjEd<3&#bXuq#i4~xJm>!IbsSc+lNbx zMV2rlW!ZJOg?#DE$9CI}?B{_kPjT_R;?ZzIrnmJrGav_P3p59N3L=XLW&h*3@&`#0 z81slI^8egGU}gjo1so56$%TywQ0{2S8LqV&WJT@P(B9I8DM*LY-xk|j@yGHO?%`XR z!iT#ZPUA?reT(2E@70gGS$mFe`|Rr%FVD({|M$oI(@#&+f)3~h2XnQ*2f(Y3#Pin- z0JsZQ(Ul~k>$6ps+y+DhAhQ^!*U8BoL{wd8q7c#*ugEtB%#2*)u(Zd%U3pCU#9QMC z|MM7Zf#71t@s=y){8@j;DgS@xg%43(_L7F+-|YZWzL5iKm-fSy!jE-#0HNYL4iNOx zP`l>@dK!EP7{gav*cy?&m8P^Sk6`u}>`6@$CBA};n!mvx$Jx~2#n7}JX@dUg7X161 z`o&jAWWc_#|3Y=)U#slT62R}pxt_cRpo(5Oq;6nfd&K#~@#6(f>R6kRQmIZ&b%-9q zdh>DNny!Y1S>h7Fr2s5kv!EGs!qLSmk16_xeDdwNH$UgQGA(>b;P}{3aW4JBFB+%+ z?M|tIfl8$yUC~cVP`vPH5quw*MUtPp(K=dT4VldoeDUhlxpj4Qn(cL>JR}gEK`8-B zODN3v)Kv*_RXBAX&tX`%+iW}Q;f4zrE=+m}Jb{PxGw(ZYnNyxY&3-+3O=$KKd0)Zr zaXJ1SlAnR82j5QeA+En~VKA&5t+e9+c-+#V)aa+l(0zBzu%VEkw527GRXNxvTxVix z0=MhS(S#2McAy?=@9ZhG4tPAVWIb24Hgo%-9+l`RHwgNFjdCA=xe?7C!k6mrhtTOQ z5+>=8_k~740*jbXpah__L;w)L5ZSrV(b3%oVXQ1{gk19w@wgZb#IbfRe2gCSlk{%x zr2N~``2W7WM|8~0@!{|A+(b4o4Polv9%wEIJ2UNIG;JAN z48HAjdJU+*bv7327X8{d~D1sc=BeC;v^ z!Kp6M{af}nv8n5g;sp}sWj3Q#65KaCe>cNB{ggP=;wyH)-(sjnZ@(0JmgbV7BI>w+ z&ZC>3gSI+8)azb%Ygw=w4IIy8KP)Zl@v7S;X*f_yb*3??%cTKviI3YVfsr}c(?31` z=17SvY}&?P@|7*2Pe#FpsYTB&V(zWhNWEq1q8=ZDW2)ybRX4Y=-!TTShqiFS7WJhD zqbAaDUZR@RTz(b=XiESo+h=ic4kEXXQcFiqEQnH5{2|l?A7$M*+(^BJ9jp!Cm>cQW z_{;}J4{<_y1YYZyEAy6?ecacji0T!h4Z(9ftos_836{6?nF3WtLRrjMy_=}oMio}Tlo;}_8 zo@KysXHphkeeS$C+)K6!3=C#C0awf&kRGQ2LS3`sO5`n>K@}tzcI8XoTvVN@%u~K{ zKL6kpl}F7xu>SoMoMB9X4@emLlb(A0?3rY# zPm{EEt!;ft>6zKksMPG_E9FN=h*;^qvYu4LB?Jxzr$&4!3$|}Q65m!{KxuaKV`ya+6?vK={WTRE7?g~l+rs4@_dA!rFFOme zIS$XBKi)U+0!Up)OmP$#+qDTa$?&s&OukyyfS1;|Wk^pQX?pQ@))`pF&xuM{tCZzw zX$tLO44ZE=lTdK$#8sWjud3DG=10!<=aj6r+l-CzFFUBdNE$A4sb<>IrA;e4fv~(l z_l50T5?oN28By@#ePy&cVs-prhjmis z2@}Z5?sWUmN#&E+X$z>RtdgBk z0!Qb>?UpcF-YpvDj0caboQ%cnwxy>;SZ?c%Zj0(Z;?!wNy4?|yS1KcaJk;86PsU0^ zC^$H@tg8#P6RcR4A^Sv?1pv4Yd-8g6gP0jx0ILs;g|V@BMlv7|7(HRdIk&2(9AXKd zf-l!@od*Qk`P;RZon-BLwc9(wUezpX3F&TdxsGkr>{c)AW`!*vsKPwuU!Hofno2l} z%^^!1s`Gc~NUdYYKdE6Y)`d#GhBJ6?7X%KjTkZn!bwwgkaLOXy#UIan@yAE=z!T@$ zzzAaW!>kO;<$Vr%y+|B zQHmyWmj0Q_%LbxlDc`$=eywMF43DQ412kVpg8O=?A$4zws`_3Mx4WpP-C(pj3E0&226|Tr*Nqw(?5TbZPpMp7O3Yw6oCe zvV@x-8E*hL{WZ27!TTmrO7eu!r~p68D}xTc-! z^Z3{r!!>@Q=gncFm%zgSlcCW6&7{Vd+#48_A*I~{NL1)9o^t-%Qt zd%)WE2VRvwp(6h{S{c8REPb0eM^30w@@WpCBJxzLfgJJ-D7K>j18!uqv;+z?H05k; zW}xF*HO{%yDlXkkEbo~YF58-BLqmkh_SQ)fNPU$3N+gM zy@%=>euVYFtRP%4Bfjwjw~N{aq}S4y$6o1zk$NGC?gX|*CR;wencTpR;pqp+qi;L9 zx$C0dy%-m0f*$X>KGnXH`HS7@Ge}R0eKXjS?NQHdte$azkI*&)OS+x=Aa7xx@pZ6$ z7z@6;buaRsfKDqE+jnFv2A%DVx48&{E3&ddw$kym2R1zWUN?#WDJ2C zeRnK%%lppkXQ3Z6iX+P6O>mv)ZJ_;>_QB8)A`=cAwD7>b00`!E8wQ$e0Zk@1D!%T0 z`6wbKY@Ksi;!KYB9ABz8vn65OkY>=qpL6h!Q+XfIQ4NPpka#<}Rah5K*dk)Z?$M(N zJ+Jwhz9Mf=V|u2*M8)U{YOyoa-;2Mh6LTD|_z&vN^yQhJ*!4hHHgiO_wzfvKB`ds6 zOS`&_ru{Km{I?uvKq6&dwny;fJ=KX5+)5ALgk03mkeWTjIX&rF*tk%_1XN01Qi(D= zY)X*|vnuW1cZgjat!h+Jfu*0RgAAIc+|_^^!3*40DqFtFXKd`ep`P`6ysXhSQuoQC z(slg4z-(Mgh)d`9Rafd1MvM!vuylV~D;Ic(x6oByZp($AoeKya^Vs3PHv@e2s`~8OK6zf#g?CTEG zW;|sulj&_}ZGCMy);&JIP&Mg>U!HorhnN8iyogN>i zVQ=&WO6@6%y004A+f62Ih!K+tKDWJH?>jAD{~~SrQTZ#-8CXMQj7h=)<8%#Z#LFs# ztY6}vZ|sQWh-FdD;hwFeOl=OcX22;jIKs;7SG%b_Lo!Io>1ySWV!|9kIs z<}TA7>K2o(!b3NQW6wQ`YfH2n&?A?g%~jXU7QB~W+7gcQ8l4Gyh{cB| z*{OGlyf-iNTI)m1pfANf(2Kl36+O%?f9lczfn(jmw~`kXR^epQ*MX!`bSXq>_%PAo z!+;~EzLKaR@2xnUkod9Yx|(e07OR>-8Dp?y9r~<9x2bB$|MwLqK z=;sy3*LKW{-`5EGln-An-WWbJ&$k!bynDiS@0HiejzxZG^%P>+Ux4&4L)X*V-&^T` zUWYePDJdyg1C(20U)P2p0z}TjCZg4hgjyu#$W21)9_kqE=J&*F`I=?;%??kWZ%f?L z_LX)%6$yf!p8FtAL{!2ppz#;eJDbd`Q~2i3QHbBU3q6Kc?=G#$n=Hf{QUO!RGV7VC z@O>svM@OY{>!n{z&wnhH{@F93&JcI@*K>J(yCyA7PoryJYrG0?(raHJ2BSD3T6Pa^ zb}(a_83E5OrM8Lul!eapt>bW3tLEV=hUMoWmHA?Xm~Kh28%Z~Z-z5O%^%blqP^A%)Vqeg6P|s+4&@F@D;5xNi{)6+ zff@)7&dSE1%S;3$$aDEu7wn$QSHPu?20iso6>V<( zbLQxzBzB?;%SoW@I25aKkjQ731p29d=X3NcCIabkJYr`kMDwK-O|bipE=?~=0gP2vpBEb*^vDRa_^-%pb_`u+y46ke)=k9i9~I$uvS}q zx#tfw+O&Hjg5d()?bTq*zS0iybX>l`n>Off<^%~Gec8R1FRm80{gF_6t=8LcO3zt= z2~{yluYjK&3tNE6SlJl7hh`UcCEb?K%(!DX8QFim)>zDPfFDTw71|O4H8oD@><#J; zd4p^+29n>vW8m>9|2&@^G=kf@Uy@VTCAM=&q~K+vhkh5K+H%myd;TKbIW}#Ghiq1N zW`!|(*se(h*0i7_p((h#<>cDJwjA5O2MQ#sZ}t*GPemp*Z+GfmH;4K#)O7_r{kYFh zH;@nwEN9%{_{0)lqR}bQ;}p2`_|#tVgH^wm%#IJ6;7ImJO-*gqr|LJ`sTQroB6F`_OvmeC!&A&D z)GcFbo*0Mi(AWak`q1@D6T1MTy^&e!hL_V7GNh+%N6l2!mi8GNAdoZDXw&|}MqB-* zeptR4^x9#Ls;<>bkJB@^=QZ2G`SC*IGo+uuot{7WQJ69o)aF$D3$6mYUudou$v)8! z+Hm)z*iI5yI%7FQ=8SiF^|F*?szvU2KHb7=rk=tNhGU#ME|E<~t6^LFd!oanlmFb8 z-2GaxsSf#fcxejv4lFJ8pePqX9vi|>ijx&CpaLnR4**$|JkD?P} zWe{`9r5&EylTW4gsNWl>ynaO>-GI(2b0iM7wpJP!cVwo9)Vr$?@lKPbRwM|_0Q@!| z&$u1j<_J*#C3VRUcQgrC6toH4xI^}fEoYa$|J!$>C&MLbbf#}rU;fkt-WhUbWBtcp~(B>!t>D@|FGD>>U~h2pF=%(GBs)XWtq9XmNJ=X^6W~1 zeeToq=X9yi&WToDkK!;!TG-h0XqpS&@s$`R%aO#86(^8*mJGeOLDzzkdxO^TUHOtn zo`X_T!wL9P#i8!_o5!3gb+Myc!cC)Y#n(*r?MDR8Or5av>_5)J&^dNv!h5f3adgE$ zik_`Rdd_D-YN#QwPT53AM~^Y|T%6q0r>sHO$cWz28Uts>mQnL#e~Fqouf$bU-h6TH zSCp0-2SvZkkq=+*Rw0p}F`B##A_)IK}!4KEjE>^=C1af|G|mZAn~?n#^q z4bSL0e08u$@xV!0Eh-S2o(C~EScIiJ~()_K}DM;SStIj?6uQ%F@-{;w?FtJ@N zAA4&HUv=`o-RB>k$NguG!I@-C5{x0phh7%&&b_Z!tdA;kp;~j(q1JR5CG8OhOjX`{ zbJw7Kjd=Sn+G`*Zm*Xi8qD^26X0*#AFugEZQ49V4L?r@iKB{wfTKnDh{3}>bF|7A= zNwt&=G9k3M*ul%OW=@`u0=JJ3pKXJwPmJD!d)yW%3}~Q=53H-VRjI(s!-Lj*xNU(A z!W-T(<@HhZ$WLlAFKW^q6q|Yjc5CZlQ`dg10T>_B_#KzgSq1VBwZKSNZ5(>w&2sy8 zVHZRlrrqFG9YVvWlmzr0?!nbr1G)pMLd+t(+Xhp)QcouaQ|Cq(176WdD)$dJ(`(N( zb;m3&q8k?CeEvM-aw>d9p&J4j@uIOCyPtlyhyJh(AS2^K@6~*ct3D@nxCrgK@ODDZ zQJQW2Avdj!Egk-3WYrB>bn5DO!?w0MsW^l}a_8|>1V`rLj*4+02(yE6^AW}4u&u7d zPinF7xrOVu;wQ=ve#jrkDqlOOmsYQejM(+08Ki{znsO0FfiHZf>x^ED6+k`D-eI5ZL`Mq!i;ID zL))c3!oaJ-e(EDbPD}Bm+FdUj{G|!f1LhrT#hDKGUOEd~K2LC&VFJStyRjjD(a)pB zR@n{Bc&MJ8ORjO!_AB$5pxojIANCXb(u|?=ZrvOin8v#rG-JWaMFlTXlJl!woXUl! ztBO~|!wM3&gWX4`yuZa3%R*(;O$xaaB6@Xvc0ciPV#n~;%-5gDB9^Y1>#e>n$KD;N zDh35~=hK5L;i7+QrYF2;1de>)n~Vko+-9Uoy0mPi?N|m704Hj+wQ$^g83Rn^3peI}7jJ*M1_tOPj z2?t?@$v##8h-!x9bL$p>ThqKQo2<$fqq)ziY8Wc%FJMcE`Z z6Gg~(74#hW5WDcN#p88z9Z1NB2{0u9ZVxr#VzmK;Vi(>S&ZGww`~EiCMUU=vAaZAPWI z=4y8*?}+uVu76XuzH_C6p8kraVpg5&D?N|xV`-#1bhtIo{VCCmIggfuUCt5EKW^bs z`q#!?T6v8gr>uUAP=9}RgaE2A@JqKUTT*y4=VrsXpo2?eNx~Tj7?9e>O`Np@$ zmMW-^KFzLMtU{hyceUfr8;iR%*o1PeV${4L42?dICd@fhgK}x8JNcU3IM+j7j(A(W zaM#PQ<*B_BW%PVi2|2~9odNtA5jOzAzmC$Kq7_$`9}OiSpAIs>t{wTjJRPVc6dv>>&%c-!Z#pfxA) zm)0Bz3iqT_KE6>>q;vLY?PS)wk!EJ4IRRE?g<)-Ug9nsmrzqdmw+U}di{#(J_SgdbXPx1$(d(7-Bp`PS!R zRfRSirrSb^w?I4*ewOG%$A3gfxy|DCyTV#xbFI`I!^tvS*~DB{5MH9O*kv=Q*jFk< zojNUo(s(dm5;5Av%-UYXcO&bKg~hgotPys}sl3m=O2co6wf8!(9kZL&>9yz9&HMuj zQ`31L^kWA4;nLiy>RYGzwCfV_3SA!tjt47tM2&OQ(Ul7Xt~cf{RjbHRF1p?N;3;M3 z1W^e)p#NF}tX^6Z-Nu+!$;lm81B^2y@vcR&BY)P*jC0>~_d=0FIn? z&+Mn)y;l#=SkNv^P8{!6n7z(Ez+-rB^8F2l^1MV`anOQ|_JXxkM9t=@DIQX{1uM75J%6kbnT9de|`16fs93~kIQelhCQ`D)T*ivh9^{P#*&nw2HZIr#h ziL+NRU!#zA0v>V>%Q?Ngjf@Lt z5Mu}2HJ%}cbLW@<=JdGxs~V*qLsyr?V^~Q^@nlt8U7upIVLY!bS!nRx6F+~9Uu`wU z=SKjM@*V@J>M!_a_cT*DhcN;&W%mlp6sJ1TNs9ccY<_Y=wYl5ziR1MiLiu}JbK_l} zWp@OD*7Uw6J#=sNb^I#B!Hx`3#^(zVF+Yta@Moe|Zv66$`*DZUBCGyG^4;u;oyb{H$$lpf$c5M$wWgRlS zC#9Xch&O_rw;f@W(&d%?HbmjL=v#Kx62g2gHio^8u14-rOLxSVl6-EVP3Pd3p13uly!B52?I6aD_pZO4Bl$=o{J?` zv+F=ISl-AewbzU&!wnFoM9<#*`k+_f3dJQa^vn(t$4RruD8!ARPaN_VImt&czffKH zIcoa#oF`kH&^xJk*z6aaF2;xczElgFHfU{fV0Cv6Vdf+3^t9!o_Ihk~hW&crIch2Y zkM76N^5YS^(qe6iHF5}{1LdpbK&URv=89r`*3)wG z>~ln8KUx9D`!&wwiGMf+9NrDMWbpp7vgj`GKDC3g0lkgb-St`dcwR_|wPO5e(-Oc; z4~Z;(Ke308ZUBkD!m(PXklA6Ji#3*lx=}^$0DU(8tR_}2eCA@uH;UayD(GE!MPB_I z;vM?=5&!))00=t*ZQ5rtIrQ_d`@)arok~!nBiq_exX;SkZrYMTX^IIth+3O!@fJ-X z4Zbw)6zN=r^RV=68zgdW*Y8^Or&7zfqkTekgf9`lMZEd}Ht`?@Z*O6M+H@9! z?RkX@0Wv@BXfKyX4Bi`WE-26g%VQqp!iPYi-b}mXNx&YO4}Jz|t-%C< z*KU~m&~=nK@f2QN#j8%aVo@}n5LEg8yuwbsrMf;>RCXC<=GSFl;Bo6=;g+sC&m?fh z>{y%W2z~ikG42R#vVunfe8$H7M&KB1^6g_lb46#TLNSD&>ggeG+pc%aPefZt#QayN zN>n_~$ubOSob@zm#o-&?lr}XSm@u%is9eTBbcldIbJBY>ywSdYi>&EKNOt zOIbh>Ec^2AUC$PSc_i17gs7-nR8;TIUb@6QGSWp5p@VN8d^pC?`t0b5f_+Jr9U@S) zE8lZ_X~;)i=($gR#5W>j=P}55VnpE^0$GSMU}5X&PwK3uW3Lv*AZ(@UpynL)iRV@9 z+>Pi1K7*-?!F`y`H>F!{T+>etf(u1{hOGT+)q_TVG}M@}bm+6y&yV-e5wB}sBcK(V zT7&MoOk%WZ{2$+1@2^E!@EAB*CnT}dFYWqgY%Q24<*nC}25|K$MTbNxN2AS8Mh7G;A0~}3UCdzDvT;G`9=zLgT9wu%d%U?xi|AdZef=Nr6TZx$(wg&Lo)`8 zajmdfHmFe`l13o-|KHi*HW=MLy#vp@Jfx@a1IOW?Js&_Lh*r0gG@ZfwyuBU}&!?u; zqB6gIUlJajD6q{fUf>Pn^Of?~io>dxgGM5Frfs&-5(2hEU#Li|hHf$4oWeip0Ya+= zt^)TA(OH8Gz)8lAo^*x)!gDFu#>R`$Y??YwTICjR3baZ-3c8N5u(Cz~!yuro@#dDy z>)S#?sLj#>-AW};8Qvf#C-19vGM^d+#1>N^d9elzX=sohTZ=5-_^oIsgotJu?ZkW> zFNGo$pu|dn!P!Wu=_}xOCcivhKLac*qeQ)N1J0g}MGXA*;x`oIuF>$?oYr@mY7Qmp z>pAwxGPi-oYX&GhE0Q{Jn;27|@m%VH(HH^xM&(w6?JKl*9{U3HLshfSfX(XU=d@%D z*rjwAq+f;LH@S$UoKW|{VOd}5Ibou2YNt9p66e$ly6M$G+&P3bw<57QLBNSf4G8o$ zFi0{27V|<)<9qBwSsaEwVd|mT6XDOEkxV%PiO^MVS4b^@-fEYc1Ov=_=gv6B%>Zia zvAJ;FJkcel1rfTto)?48!lT_qV&A<3$P*Yh2WlK+ovk6{w#1EF7%oXqkAp;f69Xzt z+F+6&6%#|$jhcDMR`fnJ`xs6;UJQv7L5gvWmRlNoZKX?xMm)%8EkbZm>@Ot8$Me<* zBSlQ^ZQsX@K(1pqG)gxSCurfXGfL&^ip>fJefdNLWom>SvaW49i!Q4k^E~LrSt;Ot zk^20+k^H`%0Enf1^Tw0E_}QKFWCn~?_OC=}2dt*0nAPShS-pU7+Ju;oz=&0v@iC5_ zd~~#5Pe_P@u>n9s4()xkl#%oTv!en?J9WgX;NX(P6B~Y{_rQ2=;cfw`CeK^le%EFy z^UFpCNLT8$e)}R@@S_MoC6U?(+U-@mC0iU-UrC5w=b7NR84}n|1Rl7WOI1^+SZ-|? zTv-R&a<7?_z4{0+UwZ4F-ETn*M7Bp!y93W+HnJETIIO6-fP@6v>RojF4A2-ejK&UV z;TL+-gOI$y80!6wnVDO?oSig{|a1+?Wm zi2kXYHSUi4d%KQ!Y$c6^_Id&aukSWyy0Hp;sXdMi>Z5kooSd;p?*m|R7745tP=bYr zX4@hYp<5dKa!D9r=z9Ew7IMsu`_4f#jh)sv2*so)-o2}(R3_BtKCusTeX;BWX5KKXQSIk9T|%pTp7(^8-B%chs#aNKJ=L!+&5 zuOK5K;dby?|MgU-xchv;(dhzGjwUTv1o)wqQ8q557zkfA9k=bfYb;?4AA`eQJ9wea zv)mS3s#;E=9k&-h)#-R-)Osfw8a&2zE!QpUngIHAY~sz9t&22M^g1xjml{|%no!B# zO44)W0M=ZcGvQYQ)tpC-Z<>yeYLuHsLKZtEwqB$CqUBBdYm2ENf(Hw{vY4@np>aY= z&DLn8^$OD4X1ig}y#QSl@Hjpzi@J%&MeQ9$3l^MV+XL-=%Lpq*VHBbs=THs*2c#%3HgVE!V{+Xy%qpB{Jh`s zAg)}__4%84`Sp$(x!!i~(wUK?k2IV+jZglqv-NXI`~BGT)o)z43mYkRdj6|ZRQv(? zdD{xjJ;kM-&%9z3gp0NYb_uAB?tIRbfJz(W7-gRI@^~eFc**3V^%GcBvs!`J-xp<1 z5#IbEtItCxIO=+GL~8(F%N-a9g`UnDFMsm+c<)M)=X;R*z5`>4?#;Chj@b2XA>|Ex ziUbM}&8R>00^)!>o!DvS3hEN;xk{iPDXpd6lt^u`2O>&{dz{g@*ugF$&|hQPW$fjv zSKnPi1AKsMx`q@zx5ew}nVB|#VQ17iq|JxbRg49EJwPku?7ndRe5=Uz7vn{YSb0Qg zSo$FpaMWl61e+Ov8RYBStf97^0sP~7b;`pv^Lt%A%d*UgCT|mgwN|OyvcWF6P6{qa zdc4o2!4xPI&%vuzVHGVi=LTk^f$vB$6>)`u87_%6^V?gc9qi{Q;`l84KJlCHH)$rP zk0e*R5sEP8Mz%T-UC9O=f>tHq_FQg3yAY~-mp`#&CtTIx(Q6vV?Y={y?BKUSfX#8R z*soh~`{dkz0_{GAJU)g-%L|nui2A#=L_GDBQ_r#qFtKsj2P?TGz~|U0S_`kOrXb2o zg{}qCL^bzKrxY;`TzyZnzx_n#tY)7wqRd`9+;a~3wsNXW@J_xf!Vg!V>R(Z$`?-V@ z^L~)SQ+`Nl>)^JDzVnEshaAiq>hQLpVO~D>X7L^cyN*Ov3ma30!>fgZ?fdB=Pp9@P z+`8}Nk;Us{yvGcwZ}f&@Q;pYac9RCO-);CfWfbh}o=5GVUB-WrnExF3QXYXQmwEIb z3UPlc<(`L%dwD&(i`;qYsl`Eiaiw;G&`nlejo1WEuNS?mZFQvp{F)Ot-WfVk z<&Zw7(}j>ZTwJpRYN-Q1t7ZK-b}5r&4ivOE;9?=;yvY4KuYPhjZIPL4`? z2!=X_2SLNExBrbzD3-lYpD=EjGvW|0{qJvi#1RBH{6ny-Avyp1T`^uCte${z+9)hcB#@H(Xy6(gUT zUb^(5TCBY{p-KAa_{AP-_K1IK0fbq>dsn*;gx-S!(Q-3iv=qa@RICastLc$5>o{I+F2b2E7p(|pq_;2U$Pyu5L(*nKwYa= z<(nGZ)vO#Tau>=es`s2&GWNtE;Iph)8a`W_lNT;snw_}f{iQbWDM-3?F$O%1d{0-t z>Qqv02I_2X5f9I|SsFY(KnTBJUB`RCrN1%RnrsW?PGtc;T`(P%P0%PiQ%Fi1qR3Vh z7q3xBSZSh!T#;Va0dC@O&{4!*w17 z(dvVGd=#f@1ARgx!=O0?qSpjHwYu9Fo3bu&YD`ym-!%WkvtfNI_s~P+!-~LK9${-+ z9jc*|feQi<3^k4iHD9aonyBwvsH!toQ&;yzaBFbZ!etItX54a$DaDo)zFC`QG?fK< zJv#KFUc-XC`As^^KBK)o^?`4BI`@cFUV>v`n78GuXo}Br9SGow8gsn<2%S(I0O_yc z)`9+!m0B7ven{>k1}Vgbil6Q2~4d{)))(rcHO3!REV zZk=`5dv?-E7rM+>x?55mvAyZaqEm0^$ZQvN;Q%q7*eIhi4y8S~?)O@pKwCleG|e$C@{VFa|QCx!uN-HN@FEpy-l#&{GEbJ>8BM*3 zn#r@tjvR%zYF`;lc-;Oqm2+(sdbv1CLb9vNXCfc7EC8FWCY&1mLuhhxGNkP7GKGD+ zU~*bPfdT+t!BU!g8_j`X>odU{2|~%(XKoB z)m{5ZpyVU!D|@T@-?H{q@eeWUXD!uvH*DDd624`Xtur%h1MIv{-d}*>m*Ep%X4dsv zdXo#GfE8>z=)rSq4>XTEzBL6Av%xj@0^5O<@<7`z7$|j}N+c$ht7qve^IDRa;ftjm z_u0ncu>HznJeNWnE34Ct+_9Kq%j=MOF*8)PQ}!+G(ob3xGv3@SmGD`hRtU`5Zh0s{ z7DTN<^`8M*&hFTf8>Q|x00qAJC1V1x!MXFXQ-?|uev5H9%gDWK@N+k9(|*whXQ-m& zxLLnkr=_JOIuBX9@7~tlUJBy?m9I|Nz|r+x(}tjR7w)HX$Tu^PcY_*&dc!iL!d$YG zI=q!nxz(&;>BlHL-aO+pc3D0~6Y{mF8sSx(mlLJ9-NmCB+GA@|x9&zZb{X}tLQflg z^1N2PX3c9T6c3#?@@Hwqw#Sk5fv2*?V_~gzR~;uTPRiKjIRRtM^0e$yYw5q^Yq@ zyU0GN+X{J!K{kl|Tqd@0N(F;7hL9>h-g3z%2<>~w?@h86a)sWHZ8GIM$qJOoMHe&{A z1R{2(6 z0a{)@%sJmwpA!BkoD%z(qYNzF-pwt5)U>$;Yx^@IE?v{P=MaEH#YK$-#GLqRAl* z>(eJyGbY?g(B-3ryV-bJ1y3VJdQ()#w|k{WHb+^LXA5q*1^C2YZ;`);9D3xE(U(K2 zm!vzn<1L+$R`un~o{LZLv-zh4J7QG0jfR1h1)jWb@dCx)Y zmGifTO^-s2&&9R*&N9o7gk6<+`ZWA}VP~{tm>hg-cem~j-rtfv@r@TBI0ALEMZ|y^ zj%+2p$aQlB`$xEK<+}?&6-PrnpRh0+%uDb}{rf;TTs(wnQSfVw&`gje+)=Gk=!wFj z{l1Sju0=Pa-*Q$CuQGM5TE;F|+49mrMhE4zeD6?bDYRGEJkwCua%vde73$RdDF9)Z zZ7KDL;(xWQMiL+ht%+$^&$}M>ixvJTuqaq73AzD{~zEm+RS>%BZ zq{ZV4&>J?lT-qtf&9xLO8%bPSU+?Pb%ap%lU0rjuws4p0Vmn@Ith&p)KT9$46-R^C zBGPoJ->q;d-w$h^?5$zoiRHnw8*7W{Zfp)4hf`gdEHQ5J*vz$*%vrP&4EKf;#z!iT z)zG#gZ+`Q14~mJH3+l6gIuc5ejcQg&v5M%bjPK(FNaiXEMz0IgpZ zYf7pifffV|Hr(#LzfIT4IQ%s9%Q9Lr;Za@1@aed>OkXQ8PzGuaWOL80`Ra{?Zv%4j z@*!XCEG@O~AAtf7xhKz~D$?@ni56pPz@MzEp-syNw81lLj#n8RyT40Xm{e=zU)H_} zlE~>#=adV!0FiY}2fL3Oeg|?xidwH-N8)3wzv$WDQ_SmWI=zWOqH2#+>;c28>Hr^L z-!KCP>k&Zu_44dZR#wiu0-yceS=&KPe?i$7g4Um>KntDf!|XyJ2mt97x@>E4h2WJT z#EZCjm!Sa=^UYoEc1gN36n=o5@Srs7PGBMSQGuT#VmSQ*G+Q~@7Bkbp55%q9HwQIm zfFE&Wz==yQHSCElv8cO(L%>~;1GLqmU9r#blR*zhsu49f9)!UEvG(QhP_OO(B}t^T z%916t*^(_}=?KYA_C33?@B5OXtff$PS;H9n&QKxiAp2mH-C*p?FynVm=Q+=L&fz@Y zp4aR5SJPlVpSeHxwcPLPy54W!tx>mg7KZxL{QG&2{bH8KauHlMtPwL>H*~O-wXC=V z{i2m6%;fUY4{eIkSE;B)1lOAyt)bGmth;25ZulvODA%$zS#})7hx=pm!+1pb!<{jS zsGYT=Jw;h=QwwHLoy$z)uIH;|HYY;U4I69+>?7zf{mpd(IV4$$?^#zYIrMfTI7+`- zv%Zhrj4dfHGWpC<2i@>lp?UX9GKLR65?sgz-dYzfb?%(bqu?+(L8FD2QihT9aAoY9 zHd7|0Gek7o9bXqr)Uc!R#m(EgAA?o7|JpF>FHTBamE`x4yS2Yuzwp+6kfCNdQ%Mg9 zD;-Xii`J7DDcCbJ%VK~0cq09kBjdIC`N6q(zBN4hD-bjEy5il!8vD>D$JLYKf+Vew z%e;wRM{6`2)YOkWR|B?Lo;y~C%squh%z&~>qrP(gz)KB~L)dh-IzC4)8e4W1mo26S z(-)oY%PF#}Sqa+43igr>pFG)jVj?eK-r+tbUcEX$AJiw>hZEnwx8y+P?g5dj-eLXG zPa~C1(9z7woqXYW&!78(P{Rc^&XAxYzU8X>(CH)0ETk0&`Juem=0|FOez1ETnCV}Q44gxxA96s^{mMvq)$}9btXcjI@jV@|jIDzH znz6xsz|2Ln3pww3COUBd^hKtqy%*b=Jf9Xj=EXz0h$$EOZB0oeJ#uPzB4hMReiIW7e$n2DVQ9x~fJ$Jn`4)oX^$3EWmTPqE z9-p%3sBkA4WplaW+exN5m68#y6{IhF+pp!N7nb*7zE?FLe)c4v>3E$eTqYyal1f^4 zE)z`6Q`}5RrovXHCNn(qrBz9C`fEz!a^9kkPgZJgN0QnZd925#^|8zqe(fKs!^cIk z>C>9$FAK#)d^8S*w6!7a01MCx@gs=5brkOXQKsl-J@+GLl_>BeoPQm>87!p4N z_wUEy8Xx=g`6h?EI7CH%N@1c)v|ac>MOS-E7nE6p_P|tY5kEvyzu2-5xxVh~q?TW^ z`bf}oJ-gJ_pes3zK%F9ronKEE=)q(;g_9hrM^TW#s!k%CR+wXh@ z2qjwI?=>6oItv*F}w`;~_TjZ{s+dBkoucqczyHal7G-BvG ze+)I=rn_5tr7Y-@Wh$y7-TkHIG#0*7CRW{RKC8i{lZ(efv@hP>*>;u@gOwvVql4K@ zkUX-JT~*!?zczXss42x}*%hRWio(JZn_?~t)A7=B@0-!mx_+Ag=MrPsEoA?dA>FRW z$3Nu%fO#;7hLV|%&NFnV9SSS|c6FRSoxJ50(`A-x`R~&sMOxgS8&=$A_ziPEY;ws( zy|t}_Lf$Zkn^RjNRXT#MP+_#tSh{+;JRXxb%z>TAAZRV{sw7V z-7&ZcqCagL&8cuu;Kf?LP-UwdKK6#7j8!NmD5ymsgfn|?BC4&+vl=Rbg&Xu0U?iYKD*b~J2_@9rl&GRaY0cz_Z%)V_>q z67#(t>_!zIKRU(?65%)HJ{M>PR6GW^Vwq<<+mknVupk6d0BR zle&Raz5&GwinUE>3+-bX`I&iG(4E|!wx|6l3^XppBF_kyDNa80eP1%$)v7trb?4Wn zCHKfH>c~d?7&iYC*7!4siN8BZXwiJvJ!TwGcspKS{9AWU`etUT6m(f;jbjvFy*cHi&g0S z^AvT2iUvXWnL1UQ!U*s>A-xY&s z>8-aNT{oWv_jiYAn&wnYqvIYzNQuxC+B9G0`}Rx z88N6W)?5PU)Qq`n53t9Sjqh0!3P|N)%QyEm=EnL zRK8|^mrH}c7Mnn)S*zQ6W^w@C*O*oE=50W;EG*ad)uoShfjk>RN5ulEsHkN0$9$!; zi)tq)7hH(1>P3s;=+3#u3)qfWbXb0Qan`Z%qJ}1#-JRTm6=0LN-fS|fB|XsPGTT;c zX|dTMfN~}$_uTvl+YrXosGg>~%Sb3s=c%^G@yV`F)RcW1>QizR10u@}Al{q=Vu=#(tK0_DV$0`-el~!x zqbFB`t9%qRdp2zVLd9YnDCjqR5b>vfB)oPnpav3cQ0ogVvgU^_*r;#um%qng*No15 ziAORo>Lu$;O-(g9$t9aJ+1!7O5AW@Q&8e*$=ck9EP4cc$04lCAQRxu%g*QJ+J$iEN zgk|X8UUQJ?=vWd(ZPk>jdy3s{7^FhpQy{tLmve!Agd;@&^?t_uax zm&bM+3b~fD&kgN2s>_Hq@66;&nnj($BebISH%<#i>0IRyx<;KkvAy}SzmhHT@xz7@ zy?!C`UrTH_#2XFl&X*nLW1bOYe5pn~qZbl|tH&hZttkypL}HUv5myv!kp0%iLP)oT zQ(2bB?@@UH72n}`Ht);Jh8tbV#{vU*DL6@npU$Bn?Zur!K;=iD9gw+hmLjt^vA~DZ z_WkxIDk@L3uyA8dqoFQ83slmjF6u*Dwe`fiYc5eNU?HI57=4`l($^BpZ+k9hDXiJ0ql=xP|G3EN1ZAy;f$vU6 z7`@2wyXYCLiy`_*^HcS`4xD$W&l?BpX~c<#SVb=x0Ls~>qV!;SZ=>W- zy$ zE>gz?$R6Uo3J(KdQo4_0&(K)38x1|d%qMN|RNiu!G`klEv-vSQGks~}lDpie22gx<2@C8oj7&bkl1E105vXk%Eu08tqF!S zt%plw9G#rP5o8o=R;&Ygd3h-cnwpw72*STM$TAtg?U^?0lEd9J-Vm4p<`DTJ)O2;( z2@1V<&KhtKDtWq^az8K~Ky-Zc$gV2@Dm9sEjqcawxpU_M>|1f1jr>|`TiYAZ!|)ZH z%QHY7X}HXWZM4dTFgNG%TtAnV?d?`!)mm?fBFubazOTr3RA?fUXx&^NV+8uft%Q09 z{nlrdTU?3d&s-5=!OlGJBk}R+w3<;GI?l_ja~&#GZ5hc-5!Qdimg0sp8Nf@2*brZ+q!lJSj;L{=`LqqjfR8GV1q8KVn7YNDfY z=~wzAELK6AbNpW9ks}AT8|6*#qeM5WCAAs8hZzP2VpJn6oOW^i4cINS&`fr|sv_J7AXTu6u!!{>}hg^4PYY$GXu(tv+n{ z?9#?Xyo<2;u^`{kPTEh=Ap4gQ<&@XnaDBWI5&k-=5ng?dd1YV z=kK1Tq9BE+8A54vjh#$<&sUZ3M<~uFuwP`AP9Nkxktm&!Hq7;FSsKxKi%XgUo`;zm zugFp8;B?L;E$4uNFC)t4)_JkLejAZqwqk^EH%-slNPmQsc2`&LFkh}|*Z2fMLh6py z6jKR0Ni)%9r4b=hSi0OTSN7iAnU%afNP;jKn2Nq~ zv2jY}wqNYZo&4OGeSnhOIx)S(tAGE@_wCX`Yp>K5GOXt5gIUF2sww}1SY;XewV(V0 zWyDnl!;Ku!a4sfKER5I-EGI}>z*)K79_WPi%}j%xDCb88aD(WGU~&cG(54Isx^CUsx+rUC<3QuD*UK9Ude5@fZ%Rt8WxUCE0J>~0p~!d+HO;3XJ@~9PCnh%k|h@<85&Euw_)_< z+F6SDrO|4t5XzJHKo9r_E>PqpzLtWxZUY9%W-k4jg@XBx|I%jgf-+!eCh;;84-{qD znJl0Ybeh2^!33zW-f{1$$BHne{eJSNOz{_ub;XP^A3uKl1A^^u#pXXxWq*4G0<7kK z_26HqHU9kmuUVEy6JX(UojL_dpyt4AIp6-1|C{jJilE9 zgo`X;rw$=Pe~>FKSE8?ZK`$0fkE?j{+@1e!jUcKc^aEs4<>!9Cs`hh=xM${# zXqLsFclqC6^?{ai=vZ*=!QT>pL-oR8>d~>H?0dSGl*EKQ$cZ5{=rM0WNG9N=`AJp> zmU-;D5&7>9f`TOl!Q?|f=OmM?_&2cikk*mky;VTm@wmQ`=ky1!4ju$Cr+vwC%9kuw zVqfuBBG-MlZC*(~P;e{Fg|q=7GA_}5Sc1o<;#AuZ>Y?`TX4LEM)cntmKN)O~5Zyls z&PRMn5}vOJd=3x(Rzvzn?~ttpMl|_oyN?}m$Bd)_pLe%Ny5sX*1C)k1bgCWz@muRt zxW+tKdPzyIFHOwvhLo*wl)$MZ9WNTWUir&cCY1x;$WUSZki8ODATFmgpkD^@Qo_wG zoFg6U+^^l)D^@hs0i4SWcNvp$cA01v_TNL85`qXj(mfL?3Rro5{o?=Z3ghy`!}h&D z#mF&plfbS- z1VE!PUNam1H}eDk<#h;%J73?3*5GqU8#eGYbmc=Gb0TSMY+S`xV;(vvzqTGLie84D z{QQ$FP`s9uAbTr-9pO$Aij(ToCr(TJ@ZmjV}0iXg8CMY z|*Q= z(5MVGglPB~s-rxE0%+Bnnl<${nF^|QYnPi(`}Ac-$Hulrvc<>({zntng&R6$W^E0D z&wD-^XY$&O)$mI=o+5ZQ@$(mMaBxHeRaWLuU(F}FxE|KfslctwP*L+=ey*U1pkDr2 zkmj(Jym=C+-sZFpT!c{e`81Cq%pYPWi+x&Xl#*%ET=~nFEA3{jo%Rne}MV&{(+XuVqe00))swEMsO2J_PKiV5OvQFu=6d z;bCG@A^p&|{LK`x+pdc^H^?Wi(>AV;k+|LS=gyx;XNn&HiYihQ-JouBG0~C zf|~Z?LMA4yubHJyN3j$|kOBIak&&^kiz-BPFKvGYukKp~mTQ;Z<2uTVW`i36a~nIz zH142*Hv6WAflv-UMhb1}q<3hXJ79p~X(xFhB|J_~4*B8%+2^{Ha?JS*DPXoz z{Wr=k(A28Sr7Kj+6n%n3)HBT&*&Y}eH;2b|8#lE^BnpRXRKLA2hl0V@7r*I%6#L3o zrS+ek#xIBh0#A^e{)hB!#fP4#b6ORKb?wOl0%ZBGWdAWz{`Ci+G#Jf#(q+_yY5{vj z5QhCd^}*fa@4wE#+ohyg=5EOhc`Qg&hh0qyvfZ+dj*gBJS~8h)7_}2|Lhx~1#;XI8#GwU3p?&2n?y(0J#?9<4ifgnCiLm#GP+ zm7+}S=(yRsc}3V1U1kFWI_M#%Jv??zhoV@Ewu2>Al$DKa%>UtRbinhZ_yXPn9kKkd zkb_Z)(OyoR?I@(aR13}DGu4yp;6S?Ij!q_M-s*f)8rUMIx(ow)w_oe;2W9}SeJN78 zK*P05FTON4%LcDI%gD-Jgp!S9xB#0*0K#+4R zDCo+bj*pMO4mf#vHsAB~*ZrsD?Jss<62hoIpeCR_@?Q18W;;TZN=cd=0)O&&&t*@b zJ_oL*qBWOCJn}tkEzWyeLjvw4w%@h=`bGFg{y6OfGsVKl1?IZV=ReGIGr4)c<}!`v zC5dF{Y%SSF`$L3ca6iArYo?TFpK??4wm&3qnJ4PFWe?fS2mRp-(SYoaV9eR|E_Ug( z#=!|Arn3(pTAYn?xco&`JTSZNGki&;G?|AzFJK_;`H75X7q| z>L1X`Nf&lnh2sW;bo=cGGsCgNU9YmHbbrm!{IRhg)QVg8 z8Zc44_~VWL*d?IJ=64NT#CiAN$EEOG7|_5S+ZP_%z|$rLOO00Pr|IVcX!KHNEZVWQ zqususZMZL<^_L#@p9}RuiXe7D|E~w|G%aXHj9X;2z!Bml%DY?1U)DASOU$<^cys%EhrHfVJ zRwN!BZo3MlIr)7B)m0Kh!G}DN|LBrF@YAyd5!|P?msAgF$^Rrg#M?`x21fWZ?)a0~xu5!|l?5!#`x$e}~xwGqoW*B%6R${Lf_ zRt0NC)0B|eC)G{+ES4f1o<-VFRNkzh%oiC%Q_s5OS0hG?BX{-x|31-6%lWgAGn;YO zw??MxmVgMZAP~Xbj~fdlAM5AU_F3FIwcKBlzqY(EF>;A|K*t~=EcC#`_-{riu6mAf z8~JvLf8dbt!u!JCt6;$6(Wn^==*%@&qJ{+CQQ8OUD%D?Kxj51nC{m8hJrW=2waHz+ z_EYxZzio-X-G#g~W7(YUEsaAi%c#UaRQFq6L^isxAa|E~bQilcaj(A;K^^46z&z6L z9MKu8KE09ONmz9Od;M^8hBX?W4Zf|F|MecauZuULW~;Mw4^g7-8YiOBh32f@g9f=J z(k|{M7^xTMz%ZhnuM?QG&2RLJmJPgI5T-R85*|UL)AK!$u+KP6gy^P&?(Tw))-Q3_ z{Au0+7KA<6;TC(C5HgJ%5V@`UL*(|nqxw4Z&bbCW_MMLiYV>n)lGkUWQR-orj&B-M z`UugnHPt(@--T>l98rG=*{U%O4p@Az>QKuV zIK|@?>?X3iT?NEz2Sm#bgRuX@kNdJP#lwPjTT4 z-r|E7J2%8KNqhn=^SO_SaCZ4Nv_Gvo_yGUB)FH2a)>N3M6u1=+#!AMg#82p64&=@C%9dM%mR=2M!axgttvLotb zghVqRq5FEX{Yb$k^dp%);{=~KgNCg@#Sz$A#dw6}cJ4&j|M)GtD2WOw9L{wfCJzM~ zuAY3k_=2KWT%w?J;;9&am(#QSj?O{} z9(6{kn!l zhoIy@;h?cYKk*Pd_}A}sITMUE(W@i+5MwoBHY733{Y~w|z+&q>R|VQNj#0fGP4R=W z>X2XlZx;qw3y6xuc0#dX2QB7Vs6QK}4(mqLQQI%%F6s=Y;gpWdQTA2`m;Apzx$OJE z2q?rC4ht0)qyd*5NK7|$U(*s@TR>8Zu_YV^IQ{eI2h3gs0i|{;hV76v|LWs<5#l_m zv+jK3Ie=ZqFxpekXsR9?Y~gt7A1Vz0xK_%yK%V!=jy?7e^0f!S{ya*w=O^fAT}T4- zkVI!lPUQTcLtb3yn^*`iaiSxFXA=LfzFNr*0HiJgCd7v*UJ3^=L_G0Lt zckN;3Ln#O%F7u2&x9Y*~!WeS=D%%1YE%olG;n|!cyQ@aD|D#RzvID(UGiJ}j?x{Ck zeDZ{^Pxm_}L-5%>|MO>Wp9hM!ib0Eq?TU>Xl!*ZbiUk%FN3OH7%1W7K66l?(dmmeZ zSu+;a>#}WMV$PvHJpao5;h}FC1qu%so*oRGL|ndP+gR{e?MSyX6mQdG{6~e za&nTbu^DfW{Vz|xEzn#PO^G#7n)q|)BTKg zurVK&Tyw>_{@UvF%rd($OM3;t0WEzL=ae*9l(EtyHZgjyGdi~1pzL=z!Sq`b7)UYt z)dC*yBF}*0pxvxI_1QnuzJI@}zbTBw{lNv0CH^gY6}{FIY14b80I)64eiA#9XM_p} zB#{ODpCdWjO#r?*Azg|pDS2!e8+Fx2yF4a6odr-|Q;teEqUL?c27|@j3EGKmZ2*C7 z2Wpd=r!QT4gbz3tML=)>-oSS%4`%dP`f@6&6w?27UX3lB(%xPFZ1EvgpTF3|zi*iF z4&fL&?PJ`3@F4)fJKow`aJ;Qjg~ong*V2pEX(qGF-)*CPgk9U;(vMcj3~(m0GN%Q? z+l)cAw{iiCxp#gZFua=qS0;)BuCpUG@Dd!nQSG^gQb4083XUw9k8Y3(e-U zyB@=DqrF>wp^O=z@04Bj@H(k*I?fYkF12DQXbI?dQ?#Pc1}~uw&aQjgU>XsRB<8Jn zaL}Ir!BNjE{)8)=;B~syt~R*DjH6PVY{DZ)%)ehaaJ!T*iB6v6xau&h@3bUlky2mfQ7QAP zerr9-0;*WD9{RS>X{z&S!PNU7`&L{;W-FK^OgS@JDQ#w?K$W! zI-pyu%t^qNwd$T^fQtXOiXK3Nwqx5lusRWPQQ3D&m;Ru!9p>}EdmGH)%!PHJn9G)+ z+h1PYg74QRxDJ0jH?gOY!gK{*)sRD%VP3cWBy(?y%kTBYJqhT1_TWMI!Ir2Y7|fZL z$G93RMwKx)%e3W*+NM26hLF6P*yyVZWZ7V_mWP?Nk=)!iN}5FMH=RAjSyDDoGM&e8W9PmuOsqkt)p9*-rqt?)drx}HGd~fdzxVCF zAO(pd|GEts=X!3R4``j$7*2LnmryLxN0jg9B1};GqGUN__7dv&D?-*439d~)IT~NS zJbA~q-GbUXFUPs|)9t&!s!Hoy)pAY8o<1i)xIiU)wQ*)Y+Eg|%U_86dp7Zp-E%_g| z<{xGsKzZ_usj2B`mhmDAF7`lzzw@?qq&jcsOY}}&q!_*ej11)_Ayn3uwJj^aG~p^} z!0jD*f1r^5?7eugS;r@9snU1_ZO>xjDq5_KwCX4BJ&<_jhq#Nj7F-}h4@089A_T9K*O5lVR zpS`%B;A54m9y>cyk}JR;4wX0r!X*a(2TXt;EdB89PY&DyZU$)@yL@^j^LE;CYYdm; zkq1B}#&h7gB9+{3E_d0rEs12T^+M9_ETd(s!Im4^NrubHY?>JW;fOwsZH>=vGkUir zt6VU)2~Is<-T>#Gfgk}Du4sSteVpT_K$AVPsCer0nXxy3c|wT-arc1n|AQ@&KFAQa zeyp!{LGP{lFh%So1*el#=cf-IaWcinQ&9v%5#8D=ZF%Oa_AqyUo)2Amq6e@pPg^wk z5zWynF+eYiY_#Tvb=iS3P38KJo6mfYMWvl5BJEW-TuupSG7tBio~1fHYyL{Bwyw{N<^2B5yeyUu$TaI5p)KZ?uiM$q9Fx)F>x_8tVw{}6?@0nma*K8GH*#7D`A)fY96 z!LInZs@11SL8SReS@vjia^Tu}Yv!?gx!(9E$dq;d_6cF%+*64zh(z8^5urB72 zz9e(zuYIp6?+Bc-yP*234uX^%^J0ze-NxNPs;hDf>Foc0uNuuT8n^XSkJ$YA$43;O0#k&YD?s z_1Ue2Vnp8D+XX_^bzWXvEU0a{d~GTo8Pr6{(Rmxn57sLO3(hp&HHbNrO8+_W*~zQ%FKZR{R; z80C(0p}uSH4+dTCg4!yO`H?6y1Em=b9n*=-@H^->*aP|Bi%;O$h`SKME2l11u7D#jpfs z5*wZv8=HRGUb%x?ET63N!f^M4vFr=Z5iehI=j(7d4~;!}qPOb|$7Otf17j+5t zxC91~lpPi!^`>ea>Gvt<4nv`I&9L)6z4Ls^B0g6i z^& zeh-#$53$jx>>}s}4tj*nkZ`7J^7mv2C2`Zc5rh)Zyrj5T{5sD(Xp*~}tc5HymG#xv zuT2QO|4U7ugdF7s%Tc_fGs`H)bcnbJncQVD{UCb38i$~#fhVSBRJpc5i_>Yfh&vJt z?eCZr_ZIE?!#Jz#LrU9!Gb93JCU27UuudL0)PCXVZCnY8#>fhC?BA0u;vB^M)+09} z#pbYj9Y8K5H1#eqxZ*N1rDiE={TpJ#>0Z3ZaiYSW9C*aO+LA~105-iP>yfT*!a8O^ zgV6a>noL1(S{0jRGIj|S7ZCG4#)6sJ4QbVl`y%Aby9h*?CI zw@q{Cm|&|hE%&9n1`TL=JWCQBuyZ}!JgUQ4rLlp>Pq3QXMWlr+omrhTPnH3i3~-4I zFgVjxc#LE{7Voo5t>!WSHQ)kf^v164J#*#m)~-lds_SEQ^;RB)C>t2SiE0l!HP@FL zD_1#A^d3LvMZtHx^AF%i`Bs5h2MxN_Q`on8c>vH&Ue8Jt(!vV*8q5!r!!BI9*b3Bz zO-8FWR;M%!fVWmh_ud3Tb2S} zCivo4?dou42uqiRLFG>8z0=w9eq4!ykQjvTbkijc?XOd@dS-d2>1N3)DCq8r=kH8$ zg)cS+hfFQHWyS|DgG`Wuv5Q?$i;z(tOSmbdkg6`o0B4pC4Vibo-%LS$wRU$s8ni*| zv^;Zgp$QEHdov&i#Dts$8)O)Q6L)IYp)n-rF2~G&L5==u=Zx0i^cl1WHF`qIn;;J8 z!@^z#ClzjYfyNRJ)X`H2>MO3q!oEAX1~N!~n{YjlrrYYBJ9lofrFs9>L{o$dGM_Cl z;`p6R{NBuJ7jvZNb3tCYy-&r6uVC_|CZdmmhNNyK2t9?RwRI-;81UL?`8vz!=dr-u z-BnmiLBBwU2{PE%&cxyQwThfS`naXBGq^sBP4>bc_gD_t4PT4AA{;bsmo{=z;rA+> zxbw-9M^>hKO3Ds}rPcfB2omL14}b8~BN2sD|)Pl^c+4T^$8kMy=cw>iVln#*^Vlw>vR`;Dmuy9Z*Q!ODLd zHR3!h(>v_Fa3)IR2GlDA@yC~+r~j@Dj2`;@#n7nw;IlfZC{t>8&|Mx7R61z0j?W>G%*|owsgCV z?OG~IN`~t;$#J(3qPVvuHt19I@yDF!du1|(bDH0$qQEFoOCA^eZKNn(Qyr>r1A&C8Hrt8UOEkORw5^Z_67f6PS#9!VyoNC3jB?zY_`9Nkz%W z^8>0kpKJTOPcjK7_E~=W`Xe4QfJ7S*%uX9MRr|hdJwB6c_sn-=tA>JBp!IsPuN7T# zVxrA@_pO?(F`sgaiVCK_gXtsZy$8G&x zVN;nhb5Ql7@o_@bB|eHgE-6}CfWaNear4_#5h;tPB;O`d{JbYhG~bVM48$t|YG~Klz0u@Y z!lbYE(e1HaOe|Zb0$)E+VzFElGwQwZ-hH*D2@4>r$yC4x+iB`z&)%L47fqtNz&puR;m37L)K-zCQ*!~NyBQ*|?<=T0l;L${_ z&tr`&3kMpxTF6rD(iOKcRxqMkP&W6eFG30C8xX<|?S-7d&GSQ}c2~vrZ%RZmTs_|u z8T&?2NCpTG+C1LuQ?dsn_9gM%{5E6OoZ6U3{0>)BZ1tDMCJ!8^nG0bq3KU_~)R0@p zA5*9Hrq=~}W6U7oL_l+@K_J2qrtOvPH5%cH5_>XvL&kXzGr^fCEE~BrI^tPGn96wY z)Zf>2&U_FwRZ5C|{K$sOGlDZ2ATdsBBCwO@DpDV#f^D9Sw1Bh%V_{^rD1ArPTxeeu21 zDhh^QGxgZ4oSBLjbb0_!k<kMj<55``9=02c)=0eC->d20vaIw1pU3zx00lJj|c9PxpPQKYGk${p9tBSqh2ipfyxR;ZY12OqX$zF3@0WvXqsRD^YM8 zRHoQgJR{0-X2cCvXb{<_0mNF_u||d70&e-}lMP+M-+0kTk0yWM(+6 zmDpwJU#{p`qoe{8&E+Q}9F#2z~Z{5_Zt6gbR$l0_+a00!1FA~Pk@t`D zT1a+9P|(YCQ@|ipJxkcVpzHGf*Vm3aYiW^K+=-9m@O}OD z=>EFeFlwHrY^<>37yG%c)IjBx+3RA0$_jUl-s<{I$QQY{oSUdQafY5Zr*p0~K@inC z=HnH<$cK~u%oR?(cZJ)#g0B%wglo02{~H!-AbT4f-J+@}X6)_Jx)Quxc^OEt3e?#oQnf{xF^s717;1fRkIs>SQ8 zfktLU-UH$ft6$#uy&2H=8(q{?2R4NzZhr!w_TtfLTn?%l&Iw72+QsSK94@*aQf?jf z0ZN;`=U=R1p5pXE(eiqW2=<|hI+sl6JhSEXu-JDMK{zzex6Zs-@bs&L0AJ0Vn4y1R zwO#TUzDn|ot52COppcJx{rdj0z||$qZ%LC^+(wL!)&Pd<4``n&22Z7PtNndrESELJ zkfOM4iH~5cayKRH|9F^x_DYBopX6PD>xMC2&I|!9hJ#gnNYY8GzM)=#4`33UOt4;< zzK1CnO*oV8G?H$qtE&s$TK0upahh2y*8vI_??<-kkA3i2kk4J17gNgrIJJc0(mtwb zFib*0!{TTE_#FK`_gBi$rPsmqZ(FjGjTVq`i5!6>r+H)GiMxr)qn(;3C-zycT?-+} z?%)ULqtoku=~aW?{C*--MQ!)t;@yFi_G;^N3Ou3gi0wtpz!vo*HHVN!u!<2hYv|vtUrwrJ^fk`!nh=Y z{sP4f*g!KRpe`^W2c)`gX}*=R!?6%+lk<$kzmYx@XJk4VP`|&|Ih8Q-ek3e@WW__j zc8n)!En%ic@!m_zx~i+xMB`dhKBK=2Gn|Fq)U9;$NR`*2dev55= z{Hv%=4QPDZc>3uY@m2Y8fN9)W%!mcp=n^*XP=Ryjjc zGahRXj#UkRNJ}&Q+Ql?ogr8Wsp1e+qo8LUk92DO-?mSeP=rT=x$9Kft>07*=d@Nv; z6UM|$wZ*pa4H$5td~ng!dwpc}iNbDo@Pm$_lh$7gcO!Ujby^-5wu4Gl>M(r{2EQMqcTen1Cj0^Ed;;g2kw&X4J$iv9w2u6P z4XD@B5zEwjiSB#E@7?!Srf)m>?#bYH=dz}QVRDswWKPJDWWSg#JkAcyJM9JXm%TZz zt;=G`F8sNt(M`b6ZedUCCP7>uV>-|TLxN-ih7@dc64@T*<742B4l-pr!BiA zA1YmLJ0|?>Yd?eEo-!c{IFTDdN z5KjD7G2%gPr=_q_4-55OL;uQ!y)zVV?yc7!b6l0V{+?^A;H48voI{%0uTTV=oi>Tv z8sp2orZeL)+bvxiln%xH$uEv4vmkA>TDs_r0;pI&XXR2Hq@6Lp_WSZvyqr6#c57IS z$1+7jr=g!{*Ss`0KjEr_=&h}#+(9>Yma}MczR{GUr*r2J#!HKPPKanuDT|6ac|U~` z?I#~5%vB(Xz(EIPkWQK#{Z6x$admyR!yVg(Y&Lq;z57F! zr@O-T@0zg019>5JFA`!u8?sAJHOJj2J}Va$7n=sJ+;<1+Lli&W2Tk|pna6E}k$2=; z#A%hwg~#@u)XUiCInSEu(v~TB)BI4eheMtnX*L+$Io8@XPWmjOz5$@Fk$(8ldplny zdN?`c@9^B2WPsJ)TBBlrRocYBZDtO813+=+XWf$56h#}h1AlOCHsTsdpRwx~ebGl1 zn@3u6+_>Egs4sz@2R8BE+JaM&?`IMoO7C{==C4oz6pQx90iAEH4%!q7pFh7*e8+Me zN!EU=f=0ia(t!XOu7NZFlD7sPbCPQ#LT1qp;Q&J8 zBWgIG;kWN=<_F!}?exNU#7m`FS1RTV^s@V8@!oTk5ik_>G(CJ(o7i^`CldXXy8k-U}UzQ^q`)i=Q6_U&34jph`chSCjW)}B?h2%vg z!0ZQ`jEJYKHS%%nXespZ((p}IL8(`EV{}8Sjm=jTs4~=^Bd>Urlq|)mA#aD587SK6 z_I%Im-wdhb;O3U;=^2*ETVXmyO+B_xixd)ihXS0yoS?K&ob_;dAk&cP>+$s&Hr!TZ z|27d;PF2UJX|6cGq$GVs@!VBr`oUVoO)3q)><9(nTSAYXd^^t~REORF@v_aD<5-q_ zj9R%j$8Kn=#N_9qr%Xf>bQh6bu)7?1=4rB7XUMVj!0Nl zB)-DnD`yDV6wAe2&omge$eBn~;Fz8BNF9$m8v6+D@Nv;(e9B>zktx4-t zY#aAPvn)omcJYy2Ohk~){_dRl>IgX(fszT@!HMA!-5)u18ubJ~He{cMdDnB*ru$(i z#{D>aruX{F^;Lv`zMEjD_Ri8SW-1O1i>1x4RsaQETbnXespCkb7>)&2w^_xmS?HaYaUBGRO#+pwy{0|`rWN{@nKf(f&g_5 zq7eLWKMn@pY09sonqA`&{ny@q5K|w_;KQO};1cc84adPzUpEBsO(ZvN3|^zs^M0x8 zw^-^p$4V8?r0bPcm~wQ4O5a;~%wtQWJ9$5WfQjfj4owkKnXfm2s+anC*D+Yz&=7i3 zo|$g%yR=lJTvq0UzW1Ez>cH+&w2^d0(3(0-i`k8uX(B8v%=3ibhIs?pwxWAmaM_hw z$oWMx;;DkF5olc8yOf?i#(q-^UWR3t(i#a0^36Tr#BS-Y*{r+X9KYBK z_w4+XY%aUY-CX5n7qhW`IwD2GvuXlf9dCM$;@+Hbq{uZ7sRD$R_V;3fhOCuRE#f_o zv{^@-sc-!=JU$(SuIUr<37()d3<&j-o%&G63CiIks4>zz^IMf5XZJvDkAC^l7-|Z{ z5Iz9RXS-R>IyUF^Lak^^E~%+FYnjq8EWZGSt3DW zm+|S#=n+IrM6cpGfGc>O5|ZFN71hzQ zfahp2KVd(mV44Q4&tsTdsjX1UE_?p0VY{+EOQEsI4cxyCkm- z+g-dR)5Xfm`@m&whDH7@b?{?*LbW)w*}ba&VcmNv*P>kjESf4jdGUJnruNhH`~1?P zycuN$MJN`jBJ8fP^{|B%hFY+lnjts3*fra@F_KSYIuWV_6m&k=UlcHYdy-zbNxYyI z54MDUD7BcF*c4%=BgOyH)7|@UARzC$E2%mDgeB*3f+aZBAgp_gB&1g5pDgyAeH^sF zWmyLgiS?bPWztYFva~92LXuT14vTf{>>04xdXBr^Y?uiQ5SBjjtK}eqL9$(d6w-_y zF4)`5a!zeu%GXevhix+76s-wl@V+UO>A2AG(p>ufl_ipGUyxvO46CYTO4MM;3JIXM zZzLy>wH_!)bS{92IR<6Ev z#9SjMU!prF@iUab+5@Owt3i??bcmTx-EaP^7db>i?`4$y=y1{@t|%gdug`!k|*}G&`mr>!qdT@QrpOr^V}*g7A%oVW)mt zlZG}{pZc%I>l#MCKL)xk{eQ1$At?7@LEYN9PHdt$qRr-&16*vU0MI6iH zJe+QaO47%tj~H z@Z~W^qQa(b%O|hLgsQpWsvFV{^8lTCUA|y(Cu^Mu0%UYsx+(AkW|=?ix-oA?76g}) zkkBD2J_`WpJPaOes8LCV)0XSnJ)XHfi2(VnlHd!YQNu$W`;7E=L|ZlWP$JU0i$Fwm zr612WE`J}uGTB$1G#44VfYzSb)KnzYLpvMD_LesS(NES3dJe&*dq83uk_ylQID7Oo z-LN#kx)N3N9GRxQiNc_Z0CWU8k}HOsw}2wAK&AoKH~l$mTWGE8@xv&5?ZDon!6J(- zimkaAoZ)Q9x0P8AFGAI6awbZGV|o3ito-;`rOC2zz4MX@`V0Eyh^Tf!G^$9sm2X3P z858XkGsciSolvWlnYY_On}^W!uFO7h0|~hsGe+A{2F)m#>(=-%uJgSLnWCC&7Ie0? zIdUa-h?h`_{0hGxCVU4z)^q2(XcCNG>`~c>Xtvfy_7HoyG40LGIi{l(3|6rc%BPPN zhTbxu%B4l7*y%($4@)T(DbB7i=m$Ohv;5+pBPyWiHy&~HydMQfp^$H$ZIc-se)dXw zO+6v?@WA99bvpO%Vi1^8UMtj!=z_hjVUV~5{fp!gjs)Um6?*Dkho;^D2nyeZa=Vdg zy9K1ln3#_$5no8Id^j!EIut59>jUSVwh%GTYTV8?@;4fy2bIh&%B3p(R2ikw#>xb^ z#q=m@r&RC3rO|UjPOlIQ9QTB|e9V=sI%{)TzV11LxOMp}5BN46DOKtQfaW%MU(naW zh86HH>~5_Z0e#gEV@6Qr%$E4)Km4S>RP@-=&8%x2?(Ik6h%(LrH}yd`w<=%h#A#Uf z^bVlCaRRS06ouXcTv?3xBl&C>q?ak=Hhw<8H3BdNvunrJ5094KA#!hsTHpDn^NQOFT8|J0lkgR(Z78abEK_gZP!P$SQg^x&I<2a%A>{c!e7Mi29@8b-`+t`d`~IyH8zka za6G4!7MZu{4{NKIl#9y;<}eW%>Ydm4FpzWr@$?2Oy`BSjgp zMuSKVx(711lRJ%$7!gK1R_K@m&4yH62u4{2b3;9oo0|_5?_4Z>=^&aG6voRPMj6x; zn$dADQabC;h~q&i>;L{N_$m#9--TwO16r8LUuDZGwm>55Io4+u^0JdgLe^nTm@*8* z;U|$)R@_4CPD}%YK`VKo*$j=%u88;Vdy}Uv--epmtU>p;BkLY%YioZv2aSUQ=H}82 z<}7^#A9V55w$%Ra>Ti?>F>uNO+U&eQayKU}Br=j?I@UgrcCw<2;8-_HvM|Ea#|w= z8iyXP14^;)!(YXrCW#uI^e7rZfXL-1;H>bzGgw8A<_P`TTfGKi>p+ zlAZEm64ZXUmP2GdI{ZS8Qri=xy@Z;!5z4Xb**inRm}yWOYG`apZY7wu1Lt@A^f^5} zZG9&|ULNF>1@?aDL?xw=-k+W7L6mt-~;AwH$9i zEH3-2zxzMF1^HT#Hm}i}qB|%ASMz|GB`Mrl`S|f=7AMQ%K|6obbtn7=SWNw@Vs*k) zrnISZMa|{dsabwovA;*|Op!WUYsjem6QkU#f*Qdt| z9>eDgYOo-DJeHf8k#R2@Fi2g?0YUNY0C3%0V|)S%A$e7;aT1u}MawY&D=HR&iK^{5 z&2`le3mbl?X_w6!rsmeo=zeC6CODd5vg3wY-Ny<#9|*iGxp5y!&p0#e!6^)Mz4ndA9;&UXi@2T3V()nMg{P zRw51h0#T~99_&2a9I-Yp^!&8Hba2Euguu1FSLw7CX%HX34NH|Ax)cGuDuEukl%sT) zZi=n~)eA9?&jfL@0-4I~E!&yuo18beu;=$nZf{r)!F+550s!svc;J3!x{-N>Lo~nE ziaz06JZwk6)!)YnS=4$_N4|+&sYpq;?v|MWsn6rL8J{&8E7H*ulDqkZTtilLyzRDu zCf-(EtGM`L^@g6ske)k*qeTYdeMwK^6b1~+zqeQm_toY9^WO63Z*P5&bzswXxT=?5 zEaPdBy>+ZpH@eYt^```zC&hCz;g}BgPYO(to#7LPY3n~0 zli}JE^F`)6AnUNImYbXFC1u1Kl+7{>LXPb?mGSX$y^M}+_a{fh&5we;7fJQeOKrLh zwc19vQn}Wlw4h9cQ-QO1KX&m%KSpOpb7xje#G>~J(}$-j;B|$pEG(b1AXwem?tYU> zSRs^>Qb(D4O5E22xj;AEO_A4PFzf7IDDA%e9gw!aQ4qH0G%*XPij_C+ugO9RLs0fJ znEpN@3*lW(x1bgM1C!= zMdJX688QDTM+4>o)W0jDlarIb1Bez_=F|g-=slG0+>3P!f2rN96B}65Y_R0?FSvEn z#H3|dpfB+_soldppOyV+XbnF)BIM-8jm7P#ys2sk?>Hx6xTDaw(wLGE#qtE^S>QL6 zb$eB6>-&AzN=c`ntP0yy3wPJ>uxzf74=DTQcevWR=>d1uz=p7y=L+w%M6Rse&&6Br zi|X_coQuwSl<5OU#4H}s4EXbU`K$BNw9^~;k=nnoco1K5Ssm1Dyc^l#tJ>r@HNJS? z{y5#)D}Psllzo5VIcG!vyk_kC?nWaji79nodbq;C=P!m6<9sowqL)6UYwJa-j}iFX zq1FY>t-r{+ud*0^ldlscwISqf2>f{FWT%vSlzXfhliU<~PSlLNlU05+iX(c|@@!$-(^)9k3+v3Drzb(Axe($dm&fcAiSNO-uB zHMHV+Zsnzn6FvFOF4R+4)0H+9z+Q%jUuI~TWjzufbrrag{gQMUh5cSbJBA2b@6xh` zmxUib>){>K=MuBZO^PbD<;&Z1yU)4AWv(-3*^s_jXz{%uSfV2-GLl7FF`2-v>!=_` z*wHL>LN`joj2dB$$qp%}*y2l7D2AOVt99J}8>9T^ec`fV;Xbv8qO214}Nqa?RT z$K|&&Wx~;7siP^ewMnWEJH{=MY6VGgx32I$~hz-5*_p^q^L&3vB6z0=@kiNKlV#szy`yQo1m* zNF0E?QFQ?qZ`5ENhh}t_*I%=m@4NIglvMQtg7mx|?tTX+2{w20rN)q zqm*(Yj*Wo3L2EbDmc)x4))1UAF9aR|#GetTzyJ@c6NbIC4>uUnP**ovFz>=gXLGQ! zev#HRGD@k;I?;024k@mubG>hC6LhRa=4u^}l}(gK>sepAvb~bF5D#TOiqkk|vbtX= zx0Z5q_~ni}!d=sfFZAV3##f51D-I8ZxmV`Ilp?GmUoqBC&FbKl!zY4iKTO+Ni&5{- zSJmsHzHenudx6GCiL+#15j zKw?B~LrjfAr^pY%Mm4z!<}<~qGKnYb?xgw_S>>_juWt7Zu|3F@(U;WQy;%4B z4eRcVaD3e1!MRIraH{t`4h_UjUmfP|&0F}!GDbM?P;7B-0VJo}oMIi~9-H|MGLVG_ zY%(4HsU6@1==H!L06m!2Is~#t>!qkFiP%cPw>poFbg-9No=kZIOkuK`Ey5QdSlfq3G_L@Xc(?iMG9$#zLbFHT>hQai`q) zkf6Bd_|&q-AuUbE=7!J>lZ~Rf=It--c?c8ld2KCb*9eQqj!B6LAWgX8f%pEAP}KVw zio7B@x}~U^Tl$rp+B!VM$U6^{zJ`^Q!H@r{eBotc=^ObVoN`bfpln#KKTbVB(~4A| zxq-Z#s$`3re}4n^mcnPJ`OM-#sr-4uvhl3o)=4vPAY)sfxt44B7%LdnpeK>S#-Zcb z6f+Jk=InNWRbPjsiQ4_8U2)5iDtF_n8kufa*hFg}@Uqb>i;^ZSm)fs?L|b;Ht2A#& z+?XDPf%4mAdDl`ZPff$FocNbuQ%$u^@M6qtHemz?-9v(3C3{n`D!C!|D+77hz z@vs)=dM*+9)Vc<2fJ;M>np`Q|PIs3F-2^1w{b^RY+H(X{Q*&0YuODq>Rzswo~>Wc7IVW!Su4%=$6vAD`ig}{87RF-$NBh zD{PY>%Z(L-{IkPAIxctn+1u*wh*lYXXg58*iDGNY^vP5WeSKpx&}q#rcX`tl#4FyA zZ58lm+Lr^g)x@W?c)MXkUO+Ajy%W#dVBq@L0xV)Lv-Gg(S1Uo8yx2ys0ZeLPt~+x< zT1Y*rP{LWe?Ot(@GX|-{QFqDyZKQs8jGdibhUQ>IY~A+2$leu($u5twpL?j!TIN&GMq5_0+jRnp_5Fdq%TRX^`#=4p7#hD!w`3@%@8hp z^?RK>eUVTIDk+A4t$cRt8;HUiR8Dc$vKFj{%d-)>;)NJdH;1G918M1#?FRJgch?V@ zbqjp^2=f=VH6|B@%Zc=DZc1|QaiYtDg2$VkH%;=*ZMN&RPsc23f0N?PFErHef_6Nt z4=Hroe}vmnbytn}|E+$%$M(J2h$2Y+2MckX;%-~d@^6B*I`0UTuUH!xn5IA}36N>? z`DJ>3eMLpTn&oD7wXHs26?_GZb#T&XPiba)toQ_aRJ7G7;~^!jDVVe zEqFf&lnoHfGrq!{R`S|DJ?3!55_cgZ=m~&$SSnPCt>fb1IZH`$3}(O4fx?1%YCrp< zM%JtP0Af=q#?4(Kx3%$=E{BGRiBC~c@dIQ38e<^6NcLe?pW&o!i%t!f-ozW(vhwJ- z?2Cp}-8*~BA%wazUj!KRfi~D8NO8bsFq@ul-3Jk5g@uE-N zX3~9qeY}J-psiudm&viOr;D3aA+X@#765fQt8o@NG3vL?Trh#b0WWo3mV$fdkox8RoE&4SHT47~lG1EM?{ zjYZ2J#zsm4@62?!p1+p_txR4E&5&O{7o%iYzm@R40gDa`2m&a3O^@KgvA#yJU7kl|1Bw~_|lEr3g+fA@` z8n)pUL#?LuFPnW)0BPPXaq6_XnOt$7z!ZbI-p~DS-Z;L0JWvYL%z2R8W=m!p>@! z+NHQgdS(MuWCH;2SR{<^gGy9GkTVB=rv`)?t`hS&^Uc!PPf=p3q<^s9V_SBBoGA zKx}iv#r~SZLchA8!`84XF6!~~nIzw2g&;<|g+ff({aH{xW!J5)>1Pig29fj5l!tm6 z+v`{k5SF7s4rVOW(p!gt%{+P_wyr&KQIR8VqN3gaEMrQ?{**Q6SN4Ip$hn@(=n0h@>Y#mu2%Ha>_WXf>L<<=OHhp{jF z#4&&XB=qC{3zMsq*2UH%`d#t^VE^NSOqPQFG~Q62t57m{K};%ub|C$;(>8dP`IlJy3YNqyC>DlEUzHQn0-zM`VS8_9eO&tNk&S`guUQzor& z?%Pc7MOuD>nydU|QKjncUog)jf-NpMtGe)@$SO0c)~CL31CqJ zhZMe)w1mSJ7~opZ0kB!m1$GVtAkN>x`gn~_RvN@jQ$Rk{@nTm>{=ggkm-e04ks9mW z6tzSG-Q?a54lrn1-{1JM=-4WSzw3WqdEYr0!b!A|!s#R^y_uPr@uRKWI>t~L5WYzL zqN2cHgm3r8KoNRCZ9uokyYY<&p>lgJb7Ruw-)*lcQJu43#&cXdEWHtFKFdgc-K@To>SEQoW%+9#!8bL}CbqC-Y8g?2f4 z_wH$mW;9}ptcn>qZKL8_O_6g0j&5+~%4hzpvX!nky-tZr>(*oLUdFpmXnuJ544-<= z8w6?>xkpL{DoTHG~{0Hx4rWg*QX6JZ`f5!VJ6HE zo(9nl2&?x~Ny@n>Zv}i!ai&)2i;J;IDk!)GR>+HE-yKySx@|mV|p9@UbCPCrTMr>2BRcfllz) z{-izBJy&Sjez`r;wR|!{X9A?h?2fyO9&i5B8#k5+3w48N!hCzazfe$n6=-n)gT-m8P!S`C@R#CLZtD+1!9dH-} zVjNn6w;1M>6U;zm{gl9i1b9II+@mcKHF7zg~#gEvc^79Mjw17*sY87>Pz!-66sSU+%mZJsS@Gkpf zj~ko&OdptZPE|aKju^4G&EDLZ2z@9lDu~7V5#~iLdarAPpp7nqur3|c<_W5Vq)j!N zLGldQ>QTljCRSQY=xG6v*xToMw3tJ$DCY^6R>0VMiB!j|?PyTShVu{H*oEU}+CMh8 zErIlwDc&AB)0q}zI{6HFRaeJ9CH)mo)W~L?<^FvAWW!_oHoze>JfY( zCkHoecb6+1T8$1YtnEOdiJ6Ku{cZE@LVl-3T`>TrafcuOw9q4P&1t;0x@jN?%rM4n z^^$8SzR)AC8+|_Qwl_k6Unj07eOL94Q-M7Cm?O^q6O{$i6CTrVBQ>=2u_94*#(OTF z1v1FUuTGZZr?-bz`uRC^iW?W9ON(5bjoJE`C7bdyWlB@ujQ;EddXZ4M00`X{g)cAyW1?U3%RHAXy@GmCtwb5Z|^CARM3FHZ!;=e-_Ssj z`bl_JcWn=;onI^1J}nOp*UHq$vkVUwhVU})c6X60d#<>K=r{NOcGf4C?qC$D*)F~D=A%N z9|DXfQq_P;Jm}?}LMZVQ|M!0KUlaC}XD(?Fwb4B2u=L`tZ03r(<_cwmpZa3|EPwcM zCcIn%@&Y%NsWLThI#G-4bX{_DHLo%g9Uy2eOI(v1a3el9(%0PpC6O}b|5IH1!>I$Z?4x#Ai3xKlRku1z&oWV`GcAXG}{ zqvK4f<|JP&v)=xeDm9haQfK$yOa90I0rk7~Qw~U7t^@D?DM!w;E&kPL(0F{t4zrOa zLr`o1j^w)-ku!i^UwvGwl^@%8zC=JQV4iyr3oGwT;`u2<^uIp7t(Qb?*V00z_JBUc zn!C&mSGQpk@uoLlXU{EMHz~0qkj-`raKAQb?5a_D(h$a(=Y$(}(d*w_9-jmb zlx1b$Wa849Hj~~Kz49iLXz79I0tTwBIkDyj^ec~;u!PWG^zX>kh}@~7CkK|Ud%ZiW zRbi8KU-D@lk9OJHQR}9h<~HCcV6dW86b}!(` zh4BpWf^^^@Pv>azubJ8*lHhAVBPkCjqxth13c^riBqyd?LvzP#8>cEH>-*kwOO-jD zc$xX*wA}4Pa0CE-<&RLa#>U1y#H*1x?Cv-#kX4a&e=VxVLir2&p~&NR3a*aG}hsKCpGTuoiQelWo!Y&gstOwoM&sd8$w7>W&m8EaJMt zWg9Ko1ik(>?bKGmU!84W2#8vusyIo^0r;g?e$^r-jAyQBcH}f}+O}BG3jerk|IVmU z^ZV${BlN<|3dCDe2IQ|Su;Qu$PUkky$6qx8W}14E_F%VV-QUnnKiTjY+jZ=Yb#r6X zYNswIA0Ks^(l`VJNA4e8(@tsSvFs7xx&P~IH2{(01By=`H`))dxYuf4>31uaXv!An zTtPG?C8W|Z;M6wq9nPfrcoOVM1~7zAB->dXCK1x*YqWO`m`p!Etlt7T9bgH+v#ogu zbAWMmU8Yfw<3^^W8Q?#=pYvG%7@8)lw@@(@gEIEu1R;Vv^L0AyA~)}8#a?0>kkrpp z`r~hR?*6YNPLhw3A2<~RQ>(qC%Grgbs&4o?(86jIWJK6>VeaMUXLYqm5bWFi){=Ee419E3SJFUNe zlnv;0n&du6xH9p)S2|>)d@;K>uO5B`=5eBHs0CjcwcM8$Qf~Wkey~tUdS_P&JuRyM zW+QVOD6a1?2_-&-+rMlAhrG;z*>#`)51sA*&iPH6*DG;+>!QzaK5)i?-q8eEz4=|h zR&!sfI(zP1{ZzB_N`2b?s`csLsBk1#6I(C4UzyEUOi=kZw+{q~v2&jfS`K6bND4VT zl`uRgTT;jE^GmJS8?H~{z;R4%Zx!d1QjmPB$ND?VF6=W=!ln6gViMH-{H#E|n=ozb z)r0=tPgD4J%PRqG+wk=#2eH_yGd=g8b~bzf(V(YDj7sl}j%;&R%l_1e6z5=--mv@h zHHqDwJzpkbmFj+45(n5p+hh;BPtD}lQ4r!%R~{as!l^ z;b(T8=6g@oM6%e8%BtUOO{o8A!;&2(g%p^cD)}5^GqJX@(42p6LPRs=)(AUdh`Wt3 zYmnmxGBg?9Dy@9=Y*|{r^y*_y{iV>xqoVhvnm@i}*v@#EnUxg=jo=LiWyJMBMM!wU zB5bG}e>W{Kssn`ow@=pJ0LJ3!D+?a98({$tI^E^$fn4#4iI@11Gwx*nLe(kJ*lTGs z6BCUJJ^*TYh$Mh5(YaIJw}LYDrW%G}z}W5T0!Q2&Qea?^w5uWCnHt|Cu!`m1ecLJ> z{sO6E1Ul6kGrD1h23FZ`K^fdhM!~ieCT?)8vU)_=T5Q79J!fZc;=X)AV2{K90>Fg8 z8T|JSTyqcdes6sM-CeB9+4jwUf6J}+L}U}*$CY}|C4T(LmcE$7QX-I7{ZSfvtw?t7 zSf^2Ih;wZjtPP{74y|*E~+{S@dy%TBeZb{+`z08KA%u%#{PY)UBy4}&UWlTpL&!nF-Z+?UQMUp zOqdp=wZQ=%uyz4ySFL6>X0G!r4$Y&-wM{4B82eV*<*h|aqX6`i<-omt5BC(%?;R35 zn97-2BL=n(Qc@y%7S?0@n<-6a>rK6FY-})Gd??cpWN++SeeU`%9ty#d^Fou^{Kr>6 zUk8^=4J5^|_V$hZH*efM);|0P!}=F90tIGnZn|2!j{7%{G-I_Yo~Q^wTUl5~Gx=K0 zW*qU3OQSax>0M*jN*0CjdZ#MmSAU#Q6`a=b3(Qt3gd$6_P}PMXXe?$oag|p1*{kFf zbxrfd-W!iy9I#jP${ix!zNPYtIm8MynjcYsH8E5`r;EZg5bQ!G)Hi6uhwi^RV5Hb_tQA~z^d~Ofc>$v zunZ5TjS)2%+_5&(%A0&GzaYP5U5!a<9oOU91^eZG;Ix4rNrK* zb!_uANw+(IuGxnlG=>NLOqq{H`18moL{-}Cy6DeU@~>W1j|WL!JiU6dQt^&o@e*np zZYp!*$Y;9eQaL8YL$aoIW z%*f9#kW#6#E+TYI8BATa8=Q&|_JWY!649g|*339E16G=ghx32ns@zpI^sl6_vlGu0 z68>)t%T*SNpI|jUQrIXydC-)8&@CMNv(d}o#-Ndz&kj0MF(;A)GNbU1wa$6O;UV6Z z)C{KuHX-q7?J_;b%7Vo{OsP&UFHR4A$8KLtxxua{3X{$l;kSC*HFE>DfbSXU$8_Q7 z4(UcBQH9Fx<}E*03LSh+zb~1xS|@HP$BMRZ+A9Tvo+s}$Z_v?mVs=Zw`KJY!JeD|< zN6D;ff_JFtOyez=U))xHt}&7(p3JGA&%mFtRfb<3oSMOv0hO5(DV<^$%9$={V0HEc z^ol%o1KpJa51}+}IwF6-EQ=-RPP7aR?nvMb{w?LCMjq&kj@dGmQv7s*vWc`u1;5VG z%?Mr2taa1vtfqS=jZ) zF8^sTcWBu1sBC@|>D8aFi+PFogmJ5&Ms6;ny1$iz z0Ko+s2wg0nh4S6oQ?OR*FF76}ZXQc~*tr5)FRB9j)b=~w^?gGtTrsHNxK(4N_Ic|G z+4}goz1onE{`%!sj?`Oa+2xl^nXpyi@Si{N&&&Fo`}y0qhDeRHsz}uiEO^fo4kS?dhf|j{!Kr`>kB=TP&u zhYi$wek6Y&Yf7hzU30_gdqjv3DD2)5f7qtei|I$*S!~Wnn=s)Gw0@NV`=2ZKY6C7R z{Wks`_W`%^;#RfhCg$}qovkP3bEpS>DukXwuAFgI0BQ@ansUQV_RoK=n1{VlhMyP{ zKju1gq*9+BtgSvP$5Au&0HfB7pniY&=MWlA9!dN;_eyWXP;qIt#EV?t z*qYCUwq4hr>kOEB_2Z-_bflc{1aWHIKt|B0fLl`18Xr zD=>@O@rH9B0P>?Df8|+BlX(g*b;%Jf^$`ZexnHUi-w>#Xyb*VV_hGOp5PxCJxjlI6-YZJU7?vo`wI%a1mA z5~3q|*$12?P5BQ~7Py?4fFx;h&%l(wtF+_aMUiiG*Ue$~7U|;QBlIV+fo)dnl9lR< zaNM{UZf;x(pL@`j@J}ODL#!?o9ahgiAc$m2c9^nQ{6)p^gT=}`)VbG%B-O)Qq^|VW zw2IX=XA5hZ;>Yyx(!<%`?3RmyK8F7{Mzqh8y$sRuc|GGI9O$~|a$olUu_UFthl3x9HV?yGsfiE@RDB~Y!J3W_r*7XUP8&X3i%4!!bjg8FVqhEaMvGsCC#V5qqMWdw5P#h@f+5`_F z{IbixhqY;eI1Z)%!LYW^Y@?TY*M;~LN`$yFiZZI=KSLB=_fU}T(4iL(kImU+<8BDe z=tOErrK0P$22i~DiF`k=B>(tCUJ+Saj}B&Sk+XPRWnFejpUOk~qH0GDh3De)}`8_h)PQtDz@x1MT*Inezde=nVA!Qm3$ zBoFx19GZ0Nb*S|P9BusiIr0t10-R<1`1Ybsu~gua{ZL>M5lH-x2Ku+(-UW?6BSlMR2dy#Ku1i{_jS*V?y zCx0H*e|$QxK7q$^y)sw#{LgQsVI&EZwNwj(C1OsIW)DvxuJ=OE)%A9N)|YA#duI_K zKZ*$%{kQG|9bkVHkNF&^97jWQSYflrDZN4S7c)?q zM^b%R?eNCb7vX>B0MexEfn9&$&f5RAq8E1(_Y+w<@--6@qo~guukiFKU++e`Mr1*Y zH8ig6voCQ>o+KCiBs+7=qtaHJNPKhc{P)B+013t_{f+o0`|kMKtVlH#A-h$db;~)% z2*3D)IflK{%mQeNM$l@$K9_8Wkiy_GondkI*;)ko^;?BP2birUUmVG&_>ZEbVs zL2_tepjZin#X*>)NgF_@CdBssj6FhSUC`p#A%eo058Y z$p>Cn(6&~a>saErW_NUL4+RWP$`v0DCY@1V$n9YN&*8p_vJ>iC>+sMQ!1MHR47wzC z&nMvGm}*}!5aOL}N5(iTbZCQ;zRfa8Kyw3ozo3_=_Ob}e=gn$wQlL$XwXFr|#5^OxvNF+>OsaO`~3d;N4-v&Mh*{O5|y!j{NzD(It%-| zgyO!Fdl2Xa79r;=^hqRY;5Nr!=5FKO=Z+h$wHvS{sdL-+Lz;^b=&Fw5ht`S@tj4jb zzk{!63`w>RQ*P4<*}YmGyXi`-L(wjD2@q@aK&$v%{SUz!2-NT@>gFP1~Yu5b{l&sBj<<|N#7u>xUF;lXc_)e+>B}WxDAJI^= zpc;%;1c5Kb+30hs+G<*9TSt}FM_6iciM9K4qM*F%#`H*vy@}YfnJ1ubB=Z4A-h_BJ zUZZE)sIA%#_r$))qT4mxzW; z3!fmLZLie%81MV}x9RahURI{+sTjKRoKbgeR>lX{A%gWDVEn*`ZCF2|W4}FCEk=AI|Obt|U=tC(`C@f%9 zYm(s8dfSfu7svYbaWK}o^tBE@yEtB>=ayvF1~LG)%B{Z87(O^!8DAR}MHVw~l7Yq9 zx{A$+9)l4#W&VN&<7zk6H+`>oaj4n(eyk0Tx(BS1)qyxRH9E2~n`2+kT)77ln=9QS zBT-BzgClw#NNwT{U=KRzk7EETxyK?l-BI( zblv64Yp|hO4Q=-(SPu-JX11yMb=OyBh%gsATve%Sw{=!vEvCN+sq3&;`pSdw8(EH- zhL!|E&_s*O;K>P|2U}$LEaP-0+k%@WK*mfXwM0Pry7W&y`T4VSg zO0Fk^#9Myx4&RKgMbL?C^dD;f~7;ri@c#skCSb<*}!#7N$W6 z$fH7H$~9mGa%ic1m+!M_@wzBm(cjj5W`5GOmnYsOewCXM>?VM@Y-(kU|My>F2L819>b0;w-#$B*keH4E_r1Pf6T zm?%3dG+cf2ddD@9Awx3VV?fmUSVqQ#uXes+u8NQ#z+B#8f`?V?{t%3Et6@tJ0O-)h z&eZzw$Jy8A(ICsb@-0$;-DLcZK(*_Si8B*J#ls;$-)ZNPyi=^7W>B)v*=q2 z3iOEN;T)MqsAWT6C-b&Vzzq!5uXL%`a~f`En_e@~Uo(^Tve|R0+)@(*0#m;`H=|0` z=r-%Q_XezG9jNknA4GR@QR*1<)GAXUe7#KO#toGgF&s&L2VW8tb<5%w(RE#7Nn|<; z=57I`Vyp7&X1v@1o8f`W4S5AvxU1=XztU#4p@g(+&=I7#u`4-t-CV07RI~`O%4d@e zIi(c_wLv0B%3W@D>bocgifxE)p<}Ef8c`Lhfia?rRk327S%+J2CXP zGm|xFlg47KO4S3UJU$FMWAyB5wM`n$OyZ3+n7S#df~0rf6_}1euIqoP9Ip!qHEWgP z0OTG|+Rb&BkVBS(??PE2eOfWYnw98+@8`)e2n_2^w%s}?z*`}yHSuyN-6OBB$U(&M zvWpAfffV?yZoL6=P9Gpk2}N+Z6akWE<~HI2?y}z8Y%?_~of4g{%sdcfk^u9#{y361 z?$a9mUDH}(ZNOeaG{h80xJZiX|%z63VsT2C;m)|@) zHhEc<^stI-Fz*}Bd$Wx9@9whH(ZeLnhyKmi6K9gLhHH&MzNfgD54*Rc_`eAwb+{BQZjNTNTpy2# zDgY(hZvp`yQ4=*Rgry@4=G$GXC<2nVbL3E<@G|?dp6OoNc(f3#15EOn&Q=+Wc6p4= z!bN_=*G)T)Jr@0CmN5^ufR4jVK=DXwg2$$poV;AR4kmjIGlIy3@8Q=q`GW-BV|SSD zr-J1Dr^$#1pqfMlD59r7eMwf}G$O4;?YqZ)*sFXUf+C%{ahvAQVVdjrJ#V88qC=g6 z%FLa+T1%-7RRrF$Rk-$~M6r&gcX{fU+#;{7&4-e~e zh72gUtT7)8cG?&egXEY7KZwe+-(I8Le!BdubN~&7vg=q44GjSoAi!hKNKER6Vfws6 znjGysfHAg%Qff9|{)<%dYBnTJ5-CBbc{TVObql--dr zaDs4`+1sNpz>SHL_a?O|2?_HfHQcD!p+x$p6LY{Wi{xr##0|lf4?7 z@hscOCExTWj_)BP5_-8`JAjuyUN+~j1SN}`{9>aSp!F6w1wn8 zJDO|S{U|Is4vkQr+p?kNq|yuHcqY{4eIHPS`8XWTd2RfNwJsW?F7It9tL@Ly@lAeky8Hc9p#Jv&=z*G7dHld3irVr&-cd;l zWT>wjh^>dol^~NVw$2%*P4t_c)0hXx??q8P@RC{ z=kUD0xAzeqG$5j^)GA%zD`yuW^Zf3!9Y*~{2IZHO5X|uypu}vn5nTx5d>|^cn!B*P zBtN0o9mA_vbqjdUN!v+Dc`a|$Gq19++(gsE6Ix4sncN;N?w8rI11X;jBX;GIvNBcZ z=hE=!*KY}pn9GTd-i`d`+>HGp=BsdP#sO(l?y$vgxrgP8<6n57zRcJrgOl7FZ-HY*d#IuGUyE*3t&}~P#-6yqchAe!&Mr^i|3!I4HUKR7 z*&{!SYLZO2UQUof?3K39>_9&2Q%o(b*VQGeeDRqWTV(OxFlmWBHQhrMit9Y;Dn~_J z0>Ta-KJo#|D8$Vf8WQyNtSia%NZ$kPPsQ+lZEc<4!jIQVh1Qeif_y{5kwRNiL+?BI zI8;{Je*`6UiaE+2BE6?vU6oNHyhF_imvM4E)^ybhf4-rD78a4>;@A2?k95BM4=n&G z###0qmmCQM-zVb;kM`rgtv}V3N}f1&w})HU1mp^%z2a`AV9VE$%Q!t%EwSHT@cT0L z3;%}2A9{sOG1205^_(eIWfb;*Pwjegf`n9)tf4U~$`$cSrcRZbRY-;PK&w0Yq z%Ob5*cX#*M%umpJ7;Q@ye-p7g(o!$%+84BYS!juAG?pitM=>U*>_)ywe;nfjxE=E4=QnS^txrw7wGTMrc}Ym5}R z`+hPuQWe&ET}Llsbsy?3HI%#*8HB!^qkg5Q^f3qB>Y%+K*!Wg@xwaY{M-hASZqg*tOhCF@WKm3j2$s;KDuZ2bT==e$wii1ev7jzE!L{Hn{~DZS6Xk8+s4q$$ojlCMG2 zI!)giV1S7q5*(Y1eEqHI-jZ0!77A_l_3^`d%fb?p^y0qZ+YhPe?>#(8$L3w?Ruv&! z=CFAGF?zGYQ|Tfq!JxN|MR2;@S#W;`*PXPxyKB0;I-Wf9ys-y)XXw2)a}&%Yc>kf? zMbs@tZM&VCY`D=$`2kyly6>x>`}-kjC1CrsLwq0oEV%WBy1vV~6}E4-Of6Rsw;$ht z%RjQc{_&}t;9gDRnK#!ujM5gJhFwC*)^I2RzQxE_&-cBTTRU}d6-oo%hc5o+Qk;9? zxp_4Ox7N7&tR3f#cT#n^waj$SmYe@}LF@IBiY@^W&e=9N1>pmC?#13TP3Bi{?aSAZb-LHS+CpZ7 z-yfZu0K4^Xn{Mv$B`Nk5$k{k)`-n~b{M~7zZ;MAL!EE|hUlbgN#;nKQE|$^J5QgEt z1zjgrmX_Yh-KHKRbzSSdF~(H}DJ#x1c!{Rrla{tgd`|GyKPC5dY3VvcF1^GWFJV>z zK&U^gPV=?BjT!+GB59mDCAE37D^bA@m7IEwKC}q523&DWQTw1bP_zcR08ja2J@&_` zpNaH+Q3#|tp(Zakcf1{!-&Lec&&kEO`SnQ|Z>+l5z5WNZnBEBQNPB=5=%=U~2mSV) z^Q9y0!|prDPsBt;Uza(S2E{Wjhn>B;vOD}7Yxz>|M!xsz^l{&j%(a&CGqZzR|Bte_ z0II@!yG9j3P^3$w1f>t4NOy{Y9J))oL0Vb`B#wX}-HlR*Za8#z_mS@IzMFsCnQy-P z-g$pB3^R^1pmX+q_IlP@&$AAfwtM@M6U}F0UbjHgKx#mSzMPz>(Yzjqq((lVxMbpg5a1vHsT-N4#LSkaN&61Mcrt`y0{#P}~ z0?jhbpnMICJ``6R##A;v^^`UwCODl9H&nRUb!cXy;?J9{X>MZsNt3nGe%dJ8edNh=*m~sQYaT zU-rB?;4?O1_RBpen00TtoTp~gTv@zbINKj7HpaXFG1`Cn4Lz}{t!msWhqr7^yDSg# zUFK3j0Pg#TSBn#m*y2r zL?2(7<#^Uyo!cXljmD$i+bm3l1F2Gr-ncYJ7~j7T7B31h?lrM-u{p^C; zncr%uvR1hhN>CH7<(V$l(gMY=lC9-C*b$L2qAvE1RH*4AuS=J?c+E8;9BS4KNjK14 zpUcN$OjK8eoj#~z9**dCD(GQyn`Mijt+$P!iwo-iaV#d*D(+6+dO88kTz_mW`tSRa za_@e9g+u_lz(L*jUEcN(N)~x^(tp-1+(XHH0q)ZJnM(B-?zL*QxGEiii5&~fYts5~ za?p_B#S5h5cF$zV(O^3jX7+Q-b@oo0kHjCC_Pcx^De`oolos`eX7}`b$P55G2{L#- zE!6A|JRh{Vs>rAY2nnDnF4{Dgd!5X=IW}lR{dgqw9<{H4<60pu zpRg1pVmTl*G`eXmAV5Y@WHRGDSd>!a(|B@mO;o;{NPi_O&2-g1|32`2F@Gl3E<=+( zv?ShEKa4@bpL|N_v857T*JSTGFnp&MiB)#4M&o;YIB#t_#+gfg+4>7npe_oT8O0%9 z)P)75*)3>uEB8T10I2$9&_^#Nkl*}J$1kE#|i#wT_Wde0>jE*77-UD>(n zT%C@NFFE~Tkt1vm@I!1p@1c=Ts_}B%oKmS~FJa!Bazd<=khpt~ym^tib_z$_W{uB% zRuY6)rDKYXTs1D?bk6l9flLX>MPKIf1Iorc03x5aUO+x@zPwsSM;*bzigeH{aZcpp zb|_Njhln+aVq|H|^zXMuE17o|8}sF>-xGLS8=8OR#ES+XDu%-N&(D{g`pstjb9F=c z+OBd&E~{V+`jOi9gQM9k4FGvP8H2{WcZW&kOPULYgVun&M4w@%!zkpsLAbv;v2S?P zYMR{XPjFSQ@6+dArAVT62lE*SP?ld_sEkFqZDbMN6c3Dz_3C1*9SA3D2KJYZUW}(R z<>&lLM=(!TPIa49EZPj_=XMg7ZE+MieEN!u6YytB@Qa5VdYIuCGCN@xo7I8*4kKwH zN*M(iIZ{HL8m|4Y#VW2nI!h@se7VJxA8Q=am@+Ar!bvGTo91ZW#U5fPW1~nqTpn)? zKk|}o#f-= zMd8PUeplyytS660i2s;76M9|uRy+>ysuJ_XeOVF{m5%r{$w}x*<*saUc z*=hP#6-?6CNvmooUt}D9sTf!05Sc} zm`$PhPZJRHvf=b;{rH}s|E}OaZm$pVzwC|I3UudHXgI7YEkWF-}$lzMGwD8#IOG$(E4 zy6{XY)}&E4_!;Mj2Q3}}L3)sTq`)>81oC0$V*F^>B-%VqvM>|tjibU8BVo<8oJ@3_ zReFHnbcLm9Atj~{(O`b+V;PCtU8qOsT`uo$^_6{IV%mgHyb}~t@{r4O62*i_tVpWhS1~C`(Qwz< z2|n+_@IhRTJ4ytbH8!V?|jIBddDd_;Nw!U zSp=Iy=L9!4j^ZLC-jmEB->>6U;XEH?5yRWLh^QpZ$hl*JS4 z-yDs^s7yp(o1Ff*_@>OoYoo~A7N$$1wLSS|Y|;}#_4Fgt6N(39WMmdd>eCvL>9PC| zoKmF!IiwMBU+m%Y?l?#v+<%HcR$hFxtS=Z)(!l#TxSLmXxb*2oGMN zWhnMj3Le3nPIhNHowknQ%kY&F0<-G@+-Cp?V|f|iV!3%drx0}h;k+{?DZqspgeCcY znsh^^GdF&$&my`*M=wJ{X z9i6-QyX>3w5>v+$*=;~=1V7_4d&lVuvnsQwUs_vx>5Xb`X!&ARzB1p(C%uuikC43& zd25vlwsISojwj&=f7nvZlK?y2GE=XL~JRyC10z;4D9CSNb&YIa6ux+;A%kuI?a}2d5mcJn;hpBezg+j>KmsjBc8;| z?+T6(VVU|48& z--76)tfuBSr=F6r>3^yj9^9RHzb}!&oEnpw3edOZ;VM`81D#QUYtd9XnWsm1WTslh zxf>ETvL3&uyu~`E6auh)by+PY&7CMt>id=kS=DeoT*vgKhY8TKld9e;2q&iT!ON-L zsZ5LED<4P|#4sRDWq6o-?}D6ct$MseO?3k=(-@q^pSlVw`;%K%b-G>Q@ZX>2neM!{ z6i&=Y=n*Iv`xEWP8bU*ooKg+1ptVFN<2{|`9v&@!_eG?Y{-fs4E|TXkMICW6wX00! z0aEswGeND^X^N6QZ(PyF^^K?so8hR0gv8cW74ICaZ0E*}j{Eb;m+FoY`i2 z;7nDxgp#^Tsr|D{@X40o%U(q|g*Wj&Q<|RfMpg>~_nIl9kmW~1n{530(jO%bkBL`q zB9#YY`ASmtNpAK$MU_X%`C8_KIKl(xha@8f7OPb{AWi-InM%Z8Dj)O3L$>Up6@V$~Q41|OdIgC7 z<8R--0pMEWygSBdBBiDj_KW@-mr6E|aS^x8yZsb*1z!YZRTGjdEf{}B_mRsfliy=+ zqQ>HKoJ>#2x#EIY(_i3aN}V&e3_Sz z4ARqG>m~R$>+0GvE{Cq$0`u!D|B8>N0M>Rn>faVl%*@RtvA=nPnHyvekb--{J($;K z?bvs#*{$`3h}j5-T+{`agKgg@v9@}%u`_Y!#AZ}gd#IAEJO$;;E!!kpyellRBo{sVSPAN~G>w>?@yUw7Qc-$J7w zlCh+1hsu9+b9^c+Nh-#(yuz1l&P>JU7<1MdQUbZy9;l~q+8GJl7>CRC3mrZ=-kpwq zE65#pl;|ik1?4XfhxTc~N?;}7#`c>F3ryWa59ye-Dt}VAKd>03$&W8Cq9(epZbW+a8*sU0qOD$u5v@lHjENB$)hr)MXA4Ci z#NEznuM>OhFGy-uxp5})dnc(c#nPc3X-rN=u;>ayv_(XlILFlqxv(p=itHl^e{)$6 zNt`&nhWvQOX~lrH?jfKC*RagHG+4bgZMYZ5v9CkTSD03Jc`|r`yiH+NE5NN0GR2Sr zNH#YY^P1ZC;`>6!V7lY_5N!^N@4*&!J&*|26Q;#0i5v=FsSEeCDkAihEBYjEy1z});!>vg)BO_Y1thF zft>?vT-E(ME&oD=uOvnAuH8yce~o^lbB5HPRXmYIWQ{c$Pcl|8Zj04|L@6A(kv~bl zk|Y_keQ{&-;tCHxz4VVOSW+HOoY7KIlgZ0#Z94eo*8~YLp`ZVY!>6FBjzUXO4eeZr zbmI!ySt2CdE%ssSZ{?l%W%0yZM1?9(_B}dWX-I!?N8QF7)GObjd@Ixs>!TWbxxR+I zGyc&GmlMQ^U!o}9EcefU|7OQ5*?270BJNz<***O0o6isL@mC`_2O=r*C(r zXJ>W1s7hOS{El5!Fz^=_7L3Z`7IsOmUggvXLds0MFecZBvuTBH>L)uId>YSesjp^} zZ`-o5WP0E8KO9;X|FX;Lu{;0QeGqVJU8XcoiT)X@xWkT8jcBOqCU&V{&1P883-H<< zOV@MWG$^7$5VPoh(ACxDa2amQ- zaNrtjF*Rw#_+@V4>cloRHi{;7cj0(p_13wSY5S|)ytXWDyB?R<5qH&-jc1ZXKzzNo zgSmh3?NnDU1qoptD~!~3biA7?aba`Xmj+~&rm>R z+^I-<`FcJIB8ea+J(`IrYWZdiQdjS1rDf?AeCbn8v~XR0Rs?JHN7G5p_D% z)FuwJkA^z&2HVf#iV}!gi}AN}%8-5dK9<>PFoEgTwF%2O%nX7d@0y$ZlZXNc! zNJYyKpwWX|#m?iZs;YH?4kkgtM6f~^3ikdodU$%8&sIB)6zK3r`em+UV1JRQ0wAQn zK+rvKR}%LoYBROr?>~YdE_nAbyqVV7s6FAT7oj`Hr9oYc7b1kZOQ2(Bcg-sCc$c~{+9{B%V9%B89M0IbaB zx}~Mym^@#sb8(=MFzt(z1cWqNToSK))Limas-sk7NcjzC=Ap^^sUoGUKb4>Mzd=ky z>0o-N+eq6|x6{j-Kbyh7?=|T;%XqrAU|0-g6gd{?qw9$`ZY~@bHLnS4m2W7|3#p&E zBRzG@qRvF~iVV+1PA`pXbZT7UWz}=u;9>3-x*sPmw70jVSx<8nuN^FRza@QYxv7@^ za35ya))V`7x};JY&-2-4?(g$%Zyjp1($JH2*^<@1OewdMpR#HNnkpl#kMqaVBr#p< zk+`douLs#&hH`#hcH1Iup=>|Ab(2i*-@iYy8J1+fCCqijnBeGyroG&#SdePGBN*=2 z(I$bA)L_Qknv9g){7g?0e~JTJ)sW~<6>xkG@A&%`@1|IWSQw*mOUHQ`y^v6 z8vQR~zn9RPWbm;!3)d^Y0$kLyx#lLldXjv-itlICKL3;r`8tGG$g1N|$|{Evbf)Hw zjeEU8(CDR)S}P#DatL2zlfP;(n-;2b zf3I%=m!eFS5tHw1WUa$`HWIx!hs=-fd2=M^A@(hVU~KgRkEku$3nWPn}T;-g(0Y!HEj5DHPR9wQUs(CI~YXi$I|w6D)^9p&1G7!fdx?6WxZdbMY0L@TTPp zH`!}ek*wbGn~VkXr%3lu^`~w+JVd(_Ro&X+^4qmek~(i{=_osZ&j2JIo7#U! z8lS7XG)nw>ODfXkgfN+%LfP)}p_LOOJ&g-2EB>5-?EWD6akjbz2jdlSt(TOPlxyDE zQ(|J%R&m_3XRDuq*qc7;dPC59-g+nlg>q@)pxxD-6x>%UJ-Ix$n*37?Doz!ECLT?c z*gw%GbI(mfj=ZsR^csTl@i|f?x_J@!dDn-6GY;Q;38DO(LN#fw;ppIis*_OcA0k+l z#OhH9Gw`YrnPiMLGPaiFl;WU67gkF&aVg%FU-)WP4K@h#2_4|@U2YIpmoE_-Yj~o%C-l2+@_q`e- zxwLhhgbB>Mf7V}O?A7`2ih8guxDVAmg?^(l(PC&LN*jmt*!0WHKrnyMEMqfAjHK4<6Txb|>Su&VrG!<+K7fXym$`;oDs3 zAB)s?d!emKRQzvEXkXF|0j-zFdOURPbCsgNVXGQ6!U97@fIZ_o zCCru;1v*Zs#dnGto6vQ{*4EbA-&FBZr)jP-YvcDBnVAZo8F-o$ac zV7q?){7!9MC0A`Tg2Z_B%1?XTte_ja_F{f>Q$|nrI((|aQfe#7GUTJ_N@TA6>QqU4 zb8$CNi%6ZwSUY1@*_QZ7zJLEN|E})aj{X*hpvwuw{?jLhzcabQl>Nbre?Y<^uNyF2 z@p)eS@Zw~`i4u5ZtWCUKjZswVb+j`oLn&@pFeUBjJbJU%jI&&%dJtg^yyfeyV3*}i z?KzSJcb@sURLdFr=BT7U)8`e{YEin>rgpM+bpLE9h%-)_j)rTe9yjTe{!4L}q?WhZ zx1}Y#W=9V_@fup^=3g$yz1~TQSS*TgkQoo`8AQrTwu$M|PT)E~0wmGXli8SdRVbiUw(^!n zB*h;06+~dUii`wR%gfkhXK5f)MP{ubw_o9))xm`7=jBqp>l4b{+9tw9k$pk`^n$aw z?{}80uLsWu(xq^og`GaH0?fgn?<0aP!)H%R$R9DBK3`5!4pCx^PR+%_NSz|kjQ2p3 z-4f}I5|Wnn-V$%5a-^xrIrm1eM87wbzmtgmw_jf$%iaqmfe-j+oBdL1K>z|!E#|!*O7h6{UkiBIwx-c zdG6~baE0qBX#I?W*DaziftMMzlvL4ZGS$&{yx$SgumX4FTcifL0 zA09MpXP{ZYM?*(fI5g?)=7Ysh9S!^_&b(&e#JH--1gE8)8@+Os;B4FD51haKTW*(e__K;=%ESEO-)VDbvyee$q^7eC^L-ul`s$@ z?GRT#{Z`w9Gw13k!*=B$EAVwSC31hLVqO9RW%%9!^KGW`d|SxBj&K^jd<&xS7e4_^ zJ5oNun+-ad$Q)iqlf#+C>w4O8t(`KWG?gv&l1YQeCO5#%mVPEYP3QWN=Je!Gf80|h zed8Cz@g!byoZ;W*fY|70ab;5dE1wxGat!~#8#`%aZ7V*xUSZ5k{Ppme9{GMZ=|;`uq?D z_l8$8ScVyTCULh(M!yxe`J9ZLMe?ssUyC2_WYzxo@#JDTT5pYU|C$KY5BVEZD{E`> z!8Az@_YxA_G!3UII_?1x?ur`0TBPkoc4aRrOzKBIorpW$1$w%GxZ2L$7c*r4%cGBi&+4yG%w4~ zin#e_z4qp%2uVn?SJWa<`MFEZ0LhpAI~A9~dD|^H;o0r;xK@A2v)ZS`Cid0Z^nxsj zZh}LdPlzs>}>d!9umfltAsEOWFkl4V+RKn zGSYV*bThuTv(=JvPuvnVFclyOo{1tS{XfoAgB{c;L89l45Tf7eD1)tgq=o5NmB`q|Wg5A9YJWtl?7d$0S zuOlw?{dYz!$Kp1cK2G8Y-!S-%Z~g7v>GlX_YE0YJQ_IV+l|V}J5=--2-MK?Vs8a%5 zf??jL^1az)YSM#j@ck&<)q~JYjeFXeDo)aTPxB#(c@$Q^cQ&m%%S7wb;blrUYRhMd zYMFcE^>J@hywikQ^Z4bq$RW*LK^9L6*Z_NQAbY`Gf%Mf0TjH*zE^;Wm?Vxj9u1Sv| z{goB6KG+zlEhez+wcvF)S2HRKngsS2KiN(=m!&NO*E4jn)Ax)q9czfkje99QJ8P`z z?Q^w4w_2O?f(T;w?IOs%l5Dn+l3`@6rd+*gpCP+r6dknB3wd;+a#(TLq9RDpBpV6} zY?RA#xF@Hlm5w(~zx(ib`SPG8n@XJ)*Np4RSi|C{i`sw;r?OoWSH4!t;kAq5&rB}B+qMy7LRhC@qHU$FAx zAA0UTG;B1YQ$-*K1Xx;}B6)3QyMFf!bMu{&aT^3jkuvdo+QTh6gl9@1=H`UHf0vxo zk@!Nvhhj0B22{hJ+1dWMqHXUy?E$lq` z0b6*5Ul;iJJZNoeQ#?90V|>fSY4UzLc0-BL0asWxm%hd61~k)~yM8bd%z)^~Y9edE zqSEMC4G#@x$gshpU}Qo~PE{JibdKu>#?_zZ(+J_j6`?`e$=*LZY(_nj68YVsmCB}Z z*404B9NMqe?(a6__@Qg{pkTz4N6Pzb)YP? zWUqPU?yeR&Zua@JFJU5yi`Q=)Q0ZJTz<9bm)cHD&h&Zv-n#g^Kvd?3>ndoVGNyi8Ytz}N{&=p+=G znv|Zt!>diYVr3SB^^7}w(*IV_==X$y>(whV@zmb&EG<^18z1PCV_V2>$eGv0E}0?2 z`2ufQkWVx>r<)VLD~CnFN+ey_rvr?{gcEAxmRb24JIu$aV|qQ*%<)&~sK5P8K9IXr z!>8g^>JQXBgj7@-ifeW2aNP{nUga$B$~{0|s!$+98b8WkQ`49l%hzi4fmbutOsL|b z2;W}4!6Ids=raL%cBMw0;r6kUm3E6}04LdRxh{x8`-Lv8s(R8P=w%~uaP2CZTjzGm z`P~dKs4J-(mARt4^eDw37FWHpwEz(P)Jc2c$A0Zsw`FC01UE=suxM_BrJ#ip< z8spY4!H1?TI21W!!V*IA@BaJ*_P6$AuA5H+nUv-Kh@CnmzTbT#y+3E5L9N=|hoHya z&c$_Te91(ApSZl}U6>wiAlNd=NXTk44IE^Qd4}903li_AiHx5Re@o(VRjdzEM9_@B zCxUkKyKFO!MyPvOzsEEGLudNE4bs37jiKbtx4*wXm4xQR0OdG1F44e4?FFhK}EZH5`)8q^ZLPF;S%NFxGQLkQJ7*`;Raj$i2oP)6`_#~u4 zFkV-l@qdkuCgHT2kQZ3W4kOpo`)Q5gX=L;=U$-u<54QUZ3p$|#@HMu$Tc z$SWMlk|j&Lm`Y3wnS|>J4+(vYq+(Ey!L--bPB_!*R^cUo^Y_%R>zN0e!91Jr)8gXC z-iaHPN)2bkgg47x=drUh?cZjY~|+J4??);?<#XQfVxaATdDX-*aXU-wvd zt)29Tdre4bkG)q;;14xE4PfkK&>=mE@x)-o6Y*um`_{8)I?~i<64{p#wa^~X8f?c_ z``pXOv$C>xC-6c%=_zEq%6XPAywP%;7eyQMY(3-J7$=ed zy-chd>g&nzif|nj`a2KQRr;ltS~$b1YWdW0=K3u@;>c%H;@19q0h~wbLrEUkMk!H=Z|#v`-USLJu3HBLH|&QVks?*iFFt; z(M)^eB-sLXGujqX{VCHx$k{WZ+2SvUW#hW(AX<`aQ!MBu37hT*ka_z?)AaV{dPtNT z0_hK%6X~r0hNrXTogLkj2*01wTEtYshdpmf4coC3-4fcr8PTez2$=Ldz1XfmM&zMr z?VN(_yNrie&j=Zm*tfQ~mnV9pV|^4)j>q-7U%t6niMNf|VxKL3?gqB;+EcVWG@*V0 zKLp8Z?5dJ0@je>np=F(qGz;b3LyFGIysj@EN|ltAWg2%!-L>RTgam%VqZ@tDyDi(A zo6|+8!03bWY=-dJ-v>Dg>E8w9%80;Rw39CaGG|(`ibcj{mb;#DdAiFM`WhB`oc`Mo zWIU(0(0F*MleD`aJ*D~uPZPXc_%3a0_aAyDpPtr<`aB$DLtLdP>Bc%oxQ9ircxJHo zCQIIRRlmTCoWq@onV4MJOtYY8f?u-ojp=IHGyD02COx|v_)zV$Dvms9)?sKOJnK{`g8N5FYC%xu+l3`wW6L2((;cUo#K0aQHV5| zsb}I1L?<;4+!*0PB0sZi;T0!Exy`=Nts`I^>_A3kvhJ^WW8zlZV`gJD7U zW}g4{p6(-BM4kkqt8!&#u09re#q@p#FZn%tn$mJ^$98Mzyv!Suuy#>NTn2QM1w*qc zN$HrK2ls2Iorhv&uJ)T9^#>ubBmxJn2Ub=W_a(A69FUsx@S6#~2U?%~V=)~JIcE+f z>~>UqVdpx}mT50~`}(4qM4i8I62)`dMir!Q#cAcLvLxng;dhBOfVAfIrOn;KW(Ysn z23-?H>Av;)+8~bGmNJ-9uv(r$}!-dgKtl$B&0ko{HQh z=J68td$t7NIc|(F8uWc{BJb((%){y}sXg?%+LyWj`Hvr|DH zo0+U{@JB+?M6U~qsy*^=!3A&xLj3v{>%62tuP2vd(At7h5<{~Y=h8%Ko6EaIz*!uKj=Ice$gk`$ zX1*ZbtP=2(zthzWliafU5w$HR@W#ckrztWRY}^M9;sI5KVOC_YTMg16cb7KI~8XeNhvZAha>Zy8*JGYct-f|#y#eFN`ME^KD<~jRuVxM1*5qu>2II&YLD-a=i3M- z5*4m-pD$>{y6DeCEZ$frL91>;{#;ymZieO~)iic$s;WBYwRLp5?I+mDa%+LS+Capt z`cw6&+a?9X?WO~uNKoTnJaL>JDx|2+H4*PT#-1B^23Y1KZ@&T!gN5WF|ZiOHl< z=mFTH;*yeKDyWn)zr_ecXT$WgR!}GavJOt<&bHfgb^K22LzyB}QC)uxGl;Ia07iNI zwZ>_a&Myswh^2uE%^ZiNb1uuV4^9axE5_I+Q9NT^S4=~y%ooOZe93A~8zW)k%|LQe zR^Uq>-a@VOIJZw99uM+zKU$-{GzbB*zeRin(o2iz`oNv8W~2Dh5qRks+1VAV-I&Mu zlDik%L)k`SAag3d2#@?kUWc~dm#^V!kLoF1bt8E}?X;Y$`%|1Jsdtpy1FVWpUD8~L zHMMo*)Zp9qiohEe=WMH#h=R0%UuNKxaR@gq{2ll+yS5Y~8$lW$rT1L%et$(u4$kOc z=P(g=_|F|Xxs(hQf<9M-*2-NY#`$zbCh4$V& z+|@O%t^!EZ3LrkbXU_IL-%{1{e-C9bJ-qL1Tm6ze^UegJ_1KOs=jod^$bbIs#eK;w zt<|z}!m7VKqDNY!p)@N`P@aF{CirCE<%Hn~zCEdpAJa@q(_8sXB&&QVEL@+8b zKp;PIJ3mSdnI^fuos87d<;P_4e?m%gI{C;2gvf;lLM40q0Chm^7b{JS>ETM5$nT=+ zS5{kJjDG_%J(Ky`10*LQEv03>x4(EXQvf~rjzzOnew7D2^!uk%NlWYNe;|H6jXjF( znu$heGKKMM$x_mLu6tuzw(s>>VfnmX*T-tEEasbx$7NbVwtP-QPF}0_#HE&gZLn6P z{8h)6YBe26SX>(pHi#4 zX-v1PvWb<`eC^rn`)asVzW*U3xSpGM3RPR7q%IyPk+;^~?rh_|6@5fH_=l-L(@<*( z=GEG)KFs=3zNy|a!v27ccg!)<;`UJ|$#Rf?+rv6!7!f4bggD?}-}CqgNXoME_m*l%Jr0Vd=Xe&ZE)s z;q^WJeQ^oY|8Sb>0bdAjp;M0r;eKanTDZS!Q6U+=j5N3v)zo~Pznc3ux6p;J1**{e zy43bMR-L+@Y{%rlkrg)+-!Dq6bcNi}(b0J$>gwu@{UM8U-C9T8-LkU1B>IeC8sX_q z&90E!Zry3dnnU9^G`?2Ig7UZ4eC>XD-|ydt=#xFth>Ku0&d&9RFZiR2MPhu=Dm4e; zJ%qP~>U%Z2^-9ixl9jQsu^aVXG%sIDJdPzAgqL4|GOEavw^a005fp?7EJ}eGb;A4> zaGoQLzHW0PJ<`Fb2vgzoUc7DnpDpRstZKt~)EW-vMuT1e{YVLQFV4^LV7`$bsZVSX z*I8TPRWTY1>bbx2J_Z8tx(TL5*7wEx&Yt~UOhtt`$)f3ze3wAXe1|`9-ZaTZf`7B3 z)KTvCA>#HL-WRhqWXbd)xN}>byZ}`%9t62`6;uNhFn~uZd_1CLp#iiJ zC*>OGHL1Y;Sy75YfRWy&!}GM9v!jpsR-ZBqQ*L0AyL{+4a+Tq@N4EO>l$P)K%A`t# z?^E9T+Y_NG9F+N5CTj}@$p{Djc&Jc?LQu?PBafXop-z$$j~WByrr*E0Jm^6c=c&qc zWksKDvKQm~5E3vcl$p>+kt|t{H;n!JSa53exn=tmI^A!ycmFFI`@bZ2M9|%Ibd%|M z8^~KD68`4)?yAztunOag#$LDKA^GQjy~F=k7xmR0CX&ROA;vPr`AXeXi2G!BX!6u* z=-bqXczV60U_S?4YWYV0&z{5QN?S{-BBzp+HXV1 z|Ez4%1Dj7ETAj47zdv&)%Cvs-_xIFBUs1vu3+goe=RuA$Sx(kU-9Z=g5r~K$z!ARs zIApBOjH^nvN()X?wh8EprDV0(8!eT!ja?JWhqFkHo0hlEx-C`2+&y|*d+^&eV{Ij; zE-o*JCyES%p@DuU9y*NsAgf#2uB;f2Fuq@1^zn2JIBgX?JOtKZhh^64pYrNf`V;xH zSy+E^anh-+oIapEiyN!PWpDqu^Ij@ac$uz9WK8k*;P&>S0f;SxHELmrn8RT zUfj1YSpBo`z6Mt=w#eKbIW_l|Z!75bN!rn#1O>fr+1Gd4cnh-$M;@Y8GQXoWtrjzw zn|eDt;MvMuKi;RWEt_WI^0=Log3aXoLeX0MOMLeo_*PXL%M+*&hfK6vb##eeUXUa& zLH=7A{#VE3OYEby8edhd-Kh`!=!HiHFy7?2jiUSOt3I%;kNHsWMWHPiT~XN5Rhm) z`Ci42o@>-n67Twe9OMyuCH^VqDFa%YV#;{AwbnYeCZJRk`MUaAVI+|mGX?5J-@rSV z@hf|_v$Jz>X9#$!16#wUY6()D-aX@{;Cvqk%DiC^n_YXIxhAwoEJ`8)Nyo#F^RIx#lu-M}C8VTw!+PA--iw z^%O~~ij6%~XFkk-Hw`EtLcYYdV6x4TKwwGJEVEc&+J^#U)iKG7TrU;zt9_D>8>#c< z{Or)F9ojIOs}?ic_tMO4Q;x*V?Vh*0_q0D&xkNXGBIKp`+tvJ(0;~#00 z+;@0s1FcDM3rp9=0sm@IRP7z68t(cckrmqM{r5hm#d&K%Lzc~ni~Wu~@T1;wVT(MY zPSM++>GwqlRmgr`@^()3eYX%Ajwt98mX5I8%k5*{SRJ;|jDIKeOnin49W%&P5U`PD zDn@aM-v101fg!(nFyxnJ`hOmR|H|6Gmr#$CX^4zCg|@4IYRO}O4R{X5gfuz7hJ`ZT z!lCT(XVH>~8Ty(X#zw=7(#^cBs7o$>el5OErfK`+pC%~45RC+2IcIr$V>DMb$eTHQ zEjk*HkWgZC9G)%YdGR~fU>ut7V44k0G;8>BzXh64Nl9rT=zKQP1{$n_Sr(4OpFibO z`iczO*TALC^1i)}Zfa_J98tn>-V#WFc7SD!ii-NQE6P=~LZ6r??uELD_TV?a4Cz8G zc33thWKkQs09|z<5tS67WH^Jv=rl&m*KL3`T zQ83U>zmiKOkevr{yQ|F)gZg;S|suH_pr%#|<-X|T`SBd| zTDam3ZY|z&?!=Dr<1Kln38y_&*Uql&W1{(JxPT0z1L4s}JF{Akt-;ok_b_4>#p2Oz z(B=o|LAI95oIw0pRBL;Cu9I~50AGFao3#X9hc$pzxhEr4e_9nAQ2S{P7BPPPL_NDk zSi)1DCaI_x&X+kbAkS&dl*8w?pa)5 zed^$t=g}QW-V!}ik^`68OIXI&I zk24cd&;R1%zH{;y5GNt&@7>i6u%NlsexY|ByYhZtUBE;#=;(-p@k&+>p)&7_u&qDO za`f~7k3KE?-RH@4p%UT%(zeXzoBWOdJ2*5~=bovxmL%wIc7CudO~lAxq_#MmE%ys> z>?--1DlCh>^$!Z6sb48ti6USteTlg*E}R@3(m@io!sb|h=k#=Z_^)4qlCYlW;Y_R@ zrN9!?ex}YEn_R)SQX%AwLl>;tl_^T0k_hsDea4!muZ=4mZm!P7q-t0w*FNoNC#Bc? z&KK16;~G=`i;nBKJ*DzXd?Ztb#1(7bsm;JL2?u5mb5GoGwD{;R8N1jPoDSmDC8PS1 zh4mhLD=8_h-=SNrA5UM4$*P^8f*U^6QrDRi)lyxEdRvdAR-D_ zL1LqC%@bxq3|v(=Rcy#$}gPkAUvO&yd77COs+KbDzak4F~^%h~0G%Kc4~? zUzo>B+oi(caHy_=%bfwdY$HX~#|+ROMwVK8a@9RUgM$kBT0;`Y8>6FH5XX8g@fv7- z2&-QG2R~kNDnU2pR=lS%x48GL^)mIr9oE$OKuV8IDWJX}?fKVQ0@ZBGm z0n=d6cAzE1XxmKON!L;wPYH)m>q;vM%%;uJ&Q^2o0}`Tq&^!qUdW_k&iq7^0>BWZm zOiSARC0}CHvco1i1Ef=7N+&-f%ZRGVe!sN|uItS78bz`8q{7S^DY1(vT&gzFU^gGYClt^Rol1ETh1CYw6jlCn;z&Qv<=zsW>{`(C7A0D%>Lj;Aa zo+NAc|Hqo&mGmVtl5>>u)eivWrEEm0O_J}Oc6uH@6;PFgwfLg)X0~Kx1aWaX2|;c~ zA3q~nMS{RdgJ|tJwQrk@2Kuz{+H!MqbH}qiea$NS%tl5Y9-aZvwnG8crP3{zB)~Wf zSu!-$rG4xWVLZs{09I6}#dWa2)_}PzXT|eu-v6N_LOYd#HOE4V4j(`+C7I@?CI+xL za+W(ny~2h9fV!b|bW38qluq--q+YM79^HlE}9O zf=%j5s;W_)3T41>v#|kNm*pBGEMA1;Lx#9tUMwFZB#=*4Ib9Lh@X2@|}(r z;Hd~lD7`Om9^ZOUfxb<>zOSw>IOK`8cx;iRgKGUR2mf&Fw@OPm^a0b96s1uGJ$TU7 zrhUyvhU7Rv} z4LXIjx*v&rocy1tF4`A&6+0ghBOZ)A`)Pqg4pq@Ar+1fw8@RMtSF(W6WV_@U7s+72 zu6O)tU4QhJdCCJT&>PfOEqzG@=-ZdT8_A+op7Heo`ViRC8BAkLqCiVnLM zrUKCL6!ROR(CW6hHkOwq0S~Cwy=x|+kzn36=mN$$ENX^cPL*5dz6c?IZQ!dAX5#G3 zB^%4`H&&p-tRu^9J*6C%3IgQo>I8v6^MT@5V%!TFni0?I7J$pv{i(dUk%1nBnON*SLT3sFk%*%gnH#jOXqxPRR3p5~S@YhM{~|!yIx}Q5@*y8i~!RJ=)rK;}ynk z_yp!Y**ZQq{XRceR((?QmrMZ3`Ozcb>O|Wt#64?k`xK<~HC zhMC{qYp=a}t#8kLFVSSu;`DXPDpJJPzYdy&!y#03b@vZm@Tlg_yTS*x>Rwx*k;$ri zyy(YQZU&!54 z77((V_SJT252LZDdrYSpR`gU(?)vUhe`}hE2asiaV%T&ko4IuK2uMmQqk5VP1wtz8 zd>y2_lXxTdv#B05dLjFzoSxRuN`(x8)aSg`?15^s6RFj?A}R=aFY(qsVlmP3)bAQ4 zwU5s!#$@RPi0P4!-_>Yp@mkWY-Iv9Nek;+ns|txz#?QY%qMswXScV#5WlbZR`vSKg zJP0egew+Jvj|TUkfdx>1zd6z_GO8W?vo$yvB+i&0tG>uB=X3DLZ8*qQke+N8pPtFROEg6jI;bG zU=F{NCuUAJOIWYs~ zw>?h}5uh((hk&oxXB;Y+GgA8Wjb2dO$2%%%hym;b zyw~cYkE1-ESNL6{k1`GZN^+{M-p|e;haS5}- ztiA$#BdVxqOgu02TTWGHCKg^)^DUs}mCx}rTE=olY&By<<>o5g>KMI2pEt{6l&G2v z?|)azlg;4UW*3`6YR6Rfk~2@uLWM~+vAi>*eZdE zctwTSiKy+^iQ8-L$dcanmfd0qRWkKVl6XGq;T~?5xo}IsL4a3vw{%JK>#_hneY_34 zf#ypv{1%0m-n;R9l3cgiA(JikHo$m2lkfr%pdRhUhZ|Z+pT7W*4q0?~RvI9E`R3*p zzvsjcEJ8kg$_DB;I&_^mOqyvR$W|CEmdob5bZ>7j-UJP2fr&&+k)E8o|*z&bp_9gK|82&{Ke+y5fl)XtoX z9yg~FyD1)=%zx(0skN)?S<|yW`4s2wdvi}eGCo=h*@Y*43$ghs6;Wn6Yau1?vm%~n zHp{dn5=As!cQy>C3^fc^oEvCAMSa`&VMNk;H~hyB9n@}Bl(XN_yZgXE1qW-*=%hCS z?c=?Y)~{!wx{XOUfFSO0;($bqee&CK${5eV)|>6$dQ$SA?9jJq+wg_7!Zeaqj>Is2 zl?4Ah46sQ6b9&#BGGqzs-#_&LNQE+mv+1{^A31e#t-b%6`pzTt5*1)!lY0tny7>W( z{n0>UKT7eBXE2u&V|(B-m>N9_{c5~DO2WhzDAm5K+vrevPrhqTS98BjOkD!gs?34+ zcH?FX-s>MWpZgvE7-H?K3A-+isH&`_cV;tXFe~aR_0|9wEC$F+^?u8! z=QTciw`p9`k&>hL>v;m(qr1I1+{Uc7ICa2ffMtAg}r42P<3*lT2 z?t$Gl@EJ#ZSB2zsUT2ej3D_e#x$R+q#~CP7llk4BP2#Dh)m|X)sk(DNyZ?u&JI|PM zZ5B8x%I?J;>d&EG(&ir;TB{*F7>)?CgYKTNg#mGWwjLg7T`8PJL&f|@S(nxB65Ts2 z$frG&A9ehc5u5rvU#88V$nqg+$se`f`rdwF`x0_l4H$$Rju#?pR62h7=6^EcE_~gT z#&8#?VCXzW3ws?_Xz@J&DF8LMjG**KZ2xX`MB(_UTTiIqLv&~QzV0jr9Br7?oW|fB zY*PeaK>$sYHOm17I=a>PY9Ppvkoo%jGOEe`{`A#rl}+!?YB#}@C#WzickZEiin>V9 z-Hrl7#?YtW<6u_OBF>@sv26tfvMK*Y6G zye`?{#!HBvo&`#bGgjdw69(#q8J4h&S6{acmR9d{ofw>r^5*M>T;Ei1CYaAM11cH8 z1d8G>jFNFp$Mbfyb+%VkRrbF1))}R)GO0l;4J;(|QP=)hogImOiJMxNdNvsjbvjm$ zx5-!x3Y}$;F2Pi^ll7gB!f5{BfyKBx5JTqVlDnqkk~uV~!|0eyJ#oww4F;{ zUpwxKyE)l)_+$I`bn>C7GBvtFC;9j8X$4Wmnj?rkWR!84_py~2MBhpC7to*ofE&`3 z2Y|8&I*Et){)O#8efHa}co9G{(bbvn+~cQW*k-?6guGk*BHjm|SwvENlN&>$!XLj7{WV=)}fOgVeRbiznCQzYepoQyF7D2Ve8VwX!4s z6A?iXLs4)yN??E5-;}C|?gTIo7R`NpHaw@BL=UEC6?;f;3Y>PUEYy~?t+E~?+FnQop5K9gSK$1HmClKjK_ z9pP(Cjn4KupvylVT22M`beFUWyOq zbV;|*@FAL13<2kQyPE^bqha8K-tC=p(MGtI!3#g?2FOT|9 z5E)Iv03zSvqLY#8`IGcDzz)!iMm|0UErAS1_q(V^KsqeXo_TcJjo6Md%4cRCo4opX z#m^JxmFqST8taBN)(rwB=2-x)%K=Q4k7Kx8p?>Q={xMFD2>;h`C2ZaMbfF*2r`P;Vv?%?7C zF!J;9AJ1^T6r7_Cj-CRpMg%aOVT|2>L2H9|97(CF+e2`w{z_S*N>2BMm(DZ$PXRmk ze!cwvi5svO0Hnp~2fR1`0`mCfV_)x;OhXO^GR@e>BH|bme-LLqo0a_fm_Q*|L=-!U z-9TS~-cB2+iGK5kWFI*YKe#uQ^8C3f5wz=Wy4%jeW6yoN_a8n)=BpLRSXmW*yu%?0 zOp!>5ZN+?Q0ER6IGO%*00gF!p7Jqy351!uy2~`RM(1h`nBaJTvFjcya0gRh`Y`adr zJmKTpZBP<6ZTm>~ih`CM1{v36t)p2pMM(hFGEi?z?A3>BkmiIKc?>vfEyCecNunE^ z<4AZWikj>~Vw~qjk!ApqiIqn-y6v=xdNG|nn0_jy7-d~v6U}_tCs{N6TP4~19^{GFTiKI;991Qp0V;>L54H%k$+=; z3{h%2qN`dhZEwH1L0&;jffV zvjfn~b$E)&Nq&9(>_8YxF{JiZACx)@55Fgkn*1SwXVTw-Wy%9lI29il7*KF}wHkV+ z{glM8kdzsyo7QKPxc=Ku-aI?}6jrGV4*$(lYC6Roo(3n!l*2(@73vM=Mo>>hYL1MU zXT$1bwW9aoe06tZ$)DFlgc9@T(iQ=g6UXUHsEX|Gdx)SV(v}iU=ILB>4La z*gspqR2;_zYZRpmbHv~==?SJzu;1K(-enY)fx|NZI#D52Px4pdx%2^Bg7*1C5w z9ofd2KF5#GH_opotimg08&6j7%=S_B&xB>PwYAq3#TC!K!Vj=pP}pFN(R2fF&=NS{ zw~yipK#33E;>$0f#ASA(i29_w_{GM9at!ZA2|>SxxcPbQB>`uvDK-PolRN;1rz3{- z{dSK_`FX+nRVwd#QLk68`P!40E%0|5JDL~5Z#ik{CY=Zo^8N>BSA=Qk3A0ItPo_bzC&c` zFSI=vLMh1H6~h)@Qt~iQrMlSa`z`is1mscfP6+9-GLs!g={?aiS%7Wl2lSyG%!2Mb zK7|CD-v4l$EsU!1ND`SMdacv+wJ`(tH1(b&5MIm8D>FWFz9aBD?n{wA?G_HsK&Fm{`9w~o zjawzK&RM`MvefUv{Tplfq=op*OIT=V z^singr|U^*Yv1rWVJ#>mva`w!8OXBF^dt}0`*}MtBn(wN-vCC15-{%4=52$`8rhl3 zDdT2hQp#!Kxkyo*ztw(eB-jblwtf0E_s=gFdp}y_T|mIg{m+L8-aewSlUWVrY%hkq zTNJak;joydbbQN?&!wgFblrIJ(YJL)G_UASdG2OcSWfA?@2*>Zd+64wYOVxnOd4q{ zdh#ixbGT;dr|>2E0|UWr6$O}@T`^yta!jL-P+h_tClMyHu~)xg{_zVk@CDcVU#Fpj zfhh*`VuYpdd<2GlFD+9FdD5k|L8s4cJPnvh5X-LNdX2q>w%TfNJjr_Njf8~jNMUYg zJI@U5K7y4fiDv$;glxeUyO|)7<;&sB>aLjp{FnfbIG9G025gawE{O`hF6b@;vzg2m z`xQ&B2Cv)jVZh;h>jPKc(4=+dOvDX@y$#3O@?-*F8{c9GTB#<)hsk z7u%D@%7firCsCf~@e6fqlZx*&C>I-aZadUVXDSd$KkN&t4geop4 zSl{ZX^4=S4{zEq6$q_q~8nb2P6p_?6tf%e9;jnLshxBNX%H;+|y}F9mE95K`e9loo z9EC6REZu58@Hza6l@id*Up%i1#W>SZzNa`qPC-CRej zmG%v!ZCY6}gjNA!FC7fA2|Tlen_>6?lRIH7}httIF7oAwenKxbz3bIzliHaWE~XcWX#q*gRejDV&sPN{}ZucpUBRlpoS(+bat1?z7#xY;I`Pv%<5} z_dFmlP!^c#K{t?pr~{V7&(p5V4+spB{fbLMU!lvJ{=;@lkEQqHB@BA;Ce>p>ho{Pe z%JlTST4#QUM`XmyShY2mw!JlVlIgjK%gqnYul6F218`rd_wNRfMl+rBV?+#bUu7E@ z$X5epTkWugdj=cW+wQ_edNVMm0@{ZB8G}d7unKFPtbP;Nv+P$>j?R<9eX)ernzT5n z4Xk;IBZ+yRgRFn>aodvkuC2ijCnkaqwXT>TV9UnV&r=h2&OPrUb@9Ki5e;Evzj1e# zBRIbaZ0gHJtB3-V>K>R-U_HE874dli;Z|Hoqqdehrg5djes&|GXv$~3*0K*g!n>lY zCl0Zgaeb-4T@X(}&|A^F+HaF?eZU(VA%_ASLqZ%iCjyv-&9p?^F@+sfDma;rB(UuW&eJ^N%H5>7$SZ^@5GsHY{ zZYf2tmo;Sa@s++_eA8xveZG-#ugRlL++QE^|MKh`Ex^=zXrQ-nfx^0#;Y-_&IKre+ z54v8RR!nhNnM!eF@z8lEm)K-tjy+ov>a8qTm8%*YVsG_&VhU9o?D=7_hMY-{x5DVt zYO=6wgejV-Et0Sfzl&gd99n#Ln7KZtbz#-nUve^*WZ<2f3YBD z6|mT*M(>XoD5p)@%LX$BrOc<}t*_TIyXtys``2G$R6J9spUxWd;f0y7y zzTM)4zvq6{odPu3wmR14#R57f7618gspEiH+tr<66Pxp77NNm=S+s;8Tdh=FukA%w z(*BV+bCaM7wX>U4JzWIKytU^Jy=1EZauP`KB1d2tHwLbCZ2g{`_>2D-aR8S>wnfb} z4)XgI9+Ik6pQ(qUk=ni9U+7lH#8b!EW;G6yv zCHx|2mGbB0qT?@$lHGdiTTN6z;v{&-*-1Fl!(hXU{D`lLx20sgZL?Z~PqIy%WyiFr z7jb9FiQBIGTY0FUl&#mAwZH`)>iJ#$b^ZYQ^d^qd7WaNJ6UPOB<-xil{snjLq~s27 z78_?HxTNBSi&qff&{^paq9WQ`2=~eOTz?*<5wJ1kv$LJ8ya}6Hl{$=XSAS2 zC!6Cp#(T(ft2@CdUS%Qtj;Dnanm;|O|3{bqeHqP+zzY5VF*{yF3{jERq7Z6Ob)*Mq z)$-blwM!+{xXtj81rXwK0dm}Ep>ReIDgs+Mv3uAq^kjH2zn-2RPu2fE2hg zFY`#@{JN=qF~thMSW51h_$c$yJZzOJ(m;(#Xa@T~QJzVo}mtiPn8 z%$fL&@IuE6p5L+3D~u3o%eN$#dV82wFO}@|ACYgMZcQ3ELaI{RL(QNZ}o8qosWUTS#`_{c_1IK8HKO*_= z%JW~_6~%yEaRoL0$l(IAieQq{z(0PEK5>eVqx0Nnd^VAH-h_c@a|(G5VdU$7u<(&hu38g@g z{nlM>>Fwq#Se~y5f_#N>0^ZRw78v7xCwgdP;N!0N==LvP_K#aUJ0lE5fSA*I9Fo7N zEnuLDNuu-3$qF}|+l+21FuQ6YGR}|)d<0D9d$0*Tq2HZYk;^Ee5{$V%t6u0IjO% zZDI!b`CFzAz0U{t?AN;V49AraVKBQ^$r1-1y@{KO!7PbEyc9b zR39pa)c)#tHB%Gm*9PYy^BrlBcdu(a@p0_i1nK)&_sb`KXk>qdjS2F->do;~yh_Wz z>VzB7ZO`{@&4>2o)ARiO_j5S+?!_7d$b)>!%;@>fU8p0Q4UE%bvy;dH`>zEIn?zb5T-Tw0qvx%3k!%^G1uN~f=(oOTp^_=mchp2R7|nO~cN-|1 z$u1G#;l%9bW~+6nr&w`rGpV_gzAPB!`mS3*Vb3q~Z04U@gjJiDFWe-dVEshK z9Roz&t|E60H-Qg}JiFBg-Nq$hNeJ=|;ocUJmKmr0S1?BlVpH@=DejjuJO8-<o?k+mmhOc=v%i}vmsPf^ z#jp^vWX#e)S5etV2OtAf<|dnAB_kbXJy9i_SY_QK&T^SuzeefyF?T9+lh%-?-|sE? z)_=|tU&2thoPW&6Byjt<^w1VXDVS|py{?@8LV`oPXknsE;^UVmw)fTYRrlg9v;VYI z-aHe542e>eCi8a!Z5Jj0+|$i!_fPRZd`11UrJ(v}MFsI39_kO}6Knr1^zfhl^aiRc zU>j^WS1#~s-bhfNs(FiBK9$y#v&Z zcj=h1PNa7lbbqE*t{ifIt#@$$IQ!7Hes3*XW1~^I(n5{n%Y1D@W^n1VKu=Kb zPp)mINi*0>yKLxHI92jI=7Am?5W~#2J-w_hv9GWV{RnwEHjv%?NXzs$iM=0xso6ld zp8EPVKv^(i`vFDbT0Uu$`al_w$=A+k2OxAUK*g0|DsK)eF8ab?6$6ZPk$H=TE!7Gr z1JW?tK2!201qCZG3XHpLG%_lx3w$M42gqK{ch9tT`{pX1>M0XY;3hSbZb~CY@@Vzy zzMO@R59VP8c>7D_#SgK7h8ANizOy>}r&=X;Mt;dFAN9dmlAl(}9Ojv|>TUEILnc7B z1}a)M{XggzYMd0EPlNJ`)-F2@li$zN1NBM5{_yQV>!!nDxbbh*V?bzt{TWRDG}sm(_OzL8hkO!D2F z$jf{JKxT?STbqAca2OgQ5KJ*Dpi<`L2}T>_Zjw{4w0VD7^sx|{d+ct&=>lnu#)t58 z!r>xb7kLP{h4^m^G<^Yhx0!nfw$1lyjlK_KxvCNb`krDZdAnb$1={~ifJsK%OZ#o~ zyu4#RS2Zrt-r@a0&b#s5@oZXKMzfA}5Fu3+v%`aE7r0kIWHtL=vjEuJL{i^s+BTfH zFHeC$Ps+sVoq-PP)MNfrVYlVzSQFri?pc7KK$TSFgl)ZAF9oMeXW-kq-S;1KSQUkj z3h#tYyuIZ8ywTI`&goL7=~X;ZR<{1;soSjDe|(s%cQL~`N#Q=!t=-S~Ft>4sV{RCx zPdLp&StAZzC%b<4@l&?i{TByr;}zt>4BPkYhDS>W(p$~9#}T%3{QDkgr&+ojH^)1t zUm`JmcHSr&{Ao`H>rvaL8x*bI<((tX8}o534Lw%9mgZ;TH3T*+Ts@d&7~Aa%tu4mT z9h#XU$aRTo77vO)^(jdd*x&PSqVXug>jfobg0?*BD*_vowYp2510Bd6HuaP9*jJh z=DRf7zscXt2Kp@rI&p#A+nAWp!IHS!UhU%i4m$+-JsBp-tdt{nrxp>ypCuNEdy|5M zUM2U0?rtITKt%@eG}*0WPP4zH6-m3!QuUtJqU>5YDHeV5)%e{~*rO#RTY8IiLqRQ?weJerGQ&|1!OH?d9;Jx z3EF9kGlu`5R{y4?M4pjQ@9QXo8rm8J#UZxxo-ic!G2-&qE?-hj5>kfAI_K!?2=+Vx zDu-lqriN~nQa(9Yb-|MUDW$%+Qv$+gewrHT^f*(~$O6j$4M0_|r zJ7tO`2rJUDwY^#N(PI#wyWPZ?Et=m3B7d7r|3QHU$l1n>#uW<*F0(8&J+~*f@3Skrt+52+5ND zX5Y5KS}XLB;)Y0C7*OYNSTxF`y<*^Aloz5S?Z1`SYEe?_a1;`;KgJ(H2BB%QCo$az zhDFLrek5c4RLJf+z?Eh|m48fzbMiA77e;y+{id46|Ee^&&S?%Me9C2T|9PgQeEe~% z3xHM^5i8zL`kOvLlXw%#K+)s%oXIAx>s#-&c<1%kb>Mam3{l!m;0m_`Jy3xBpwMpx z`SJBzf@;UF>n(E4ti8WKY6UepD|sE=ZuWJ68AJnK%KD2?PZNbxDx&GFyQU zfMJ^+->z4$0rlq&P&BFwDBpPSs3%c26%L+937^;@GD-Z5i-McTZZ{sp=oHC762Dhb z33+PjyDrnee{XK1Mi3HR@GT^|egh0EB4mG~F`Zuk3TIQFUj{PZoj@V_8fm}3wrU!v)aF5XX zilpPt2_bdB9#rP8yG4O~WIp_^Uq3Gl9EX#>-0=W)!g+lbc^X=F|04fe@hphE1JZ*t2vFjikkz43Ivic(?sj&c_em-0hiKd8H)*(NuG0J`=JaYsO9y?lp*4Zt zYafyvmcj&1=1${dTi+6d0|Od7BHbwYTuY+LM~bxS)rIz#XsfOFKGf6LV~ zB^iU9g;IGvYqU<>#@V&RAiWABEyyVfAIwp}nnGB_`0GvsxPv*tNMJUozh}+~zhJT7 zss3Hbw<3Lbt~ltC#SY8-~cq=pR8*;qB!Kx0gzJ{{RZq3q|Ip%Vl+! zU}{QNpcYC)MF7sc!xp`<+)}lu|DnUlQh0#(hr@ezLV!M?mc~NL(jgl&P^4%F9PreV zSJ3ZT?P0=~nSsh@R>r-1$h)^IPZ1JwVyo5k2WIIYF05Q%Gqr*1csxeR9N*Z zuV^m{y03n=nrak}VKrcz@9My#UNe?519n?-T-IShl-Mx zyj{d_?0$f&7TEMtcY@$VcgKVfg`%(?Uy^eIowfqx_^{yV)2Gr?LS7%ufH{VMerGM~ z+}=O_z)r`?+A~43U1<~G)KkG~&?vS?M}Hvj?fCQru(EFDDQ5?B7Q?p@wu8xDQc1J> znLBqlOcR2^z4(-PKF0^Kx?aO-1DPH!DjmkC6v19f`2-&3eRB!e>NwDP+_O;UgpiSz zeg{i)a6U4tW-U~vzhi-w2!MWMnaJTeChyjLEeeS=SEttU4^5G8OB)WVl#l0AbJiM` zu5&@$*Q@^`12mS+m#8JH7my-O07GH>`y|3Ld7^|yyt`_{dtVgDId4>^77wAmrCq+# z4a^oh)h8h5nJ%~0=h~m};Hh#nBUYNSLX#hJ3GRS3!cG9}(MS&W-7+D6ZLMN_IwAOB ztsYqN1P$~m9<+ZjusM1g7}&vUmP}7k#NPFpL`#-gm64sVEg+!h@j)AvNhIf0m~#wC z7)Y%khRzCZ6*%qVGC5aO=tIZ9JqO)`0yDCK>3ohh>_E{55-|aDN0z=JtISSN%abpN zL-3j%a}26-x;Q&)Z93uW*1fDBZPerEMHF&mi=q#2ntL@;2*f=|EQCs;SL2_!IaH-m z?D;wAgWdN^yua1S(jveh)O%p|Wjiq8c4em=4(F~nL(2_V_Knx=sTHVl8oVfR-?iwP zO|{z48>5J{25KNhF!aSB?sK!(LS?oi8Z<8Hpqk`(y_e!9rp06erSrB;=o<6_hJ@H9 z#X6%R>NG0CD|)WcyP3nGGtPEEu@&GQJ7}zFTmd<;+Z~@I2l-#q)0i-XfmF(pbwt(B z!!i(3UDwjH;(*tx#Joq{Mq??lpZ-+Ob82aTk?`1kj&}qCLO;06W%C%7%r0~q)d_J6 z%o6^+*o5I(m2|r86)^}svc-&JErwO6T?YiQVSZfW4B$ZL;SA_GFK2IeT7tTysC-uI z#vVv%`-mJ5;6W3WQW28)looeAzRo)!)?75xD==X_rdlj*LawK1E#^K8S4)voZ-=#E zjd-t08IWbyhBtuL8>3^>*nw#a#`5WkVlu8W5HkIUlRdtu8sw+Wv-1{F9LYVEK)vC! zZxqvHQC+MzKW0Vmv0gKXIk;+_yZs$# zRx3YhI5;)+hyzO2RMs?G+ts8FfO6_h;nRklIe7iXP4rES`ehGm z0C#S|Yi}f=dOjHiXX!)ff>H$Mu@Hi}KBFt+30zL9@eOl7KFk3IQZx$gGgYf4KEn;S z1LL@!?xLm^%)Zt3n}}X%S;;W)+>GBfx$Hd1&K}2OBo>2miy~=&H62Qj>QfIErj@Ag z?;W>b8Nix^sz8el^PTZf*JO~{U@GW2tz`R>qf`f)Q0QgLNQ&1gj1L7BI?zPQ-d)W> zQBIyld|-=6v;nh1Y4p&mZqtn(CPVGvdo(@elp2NXPimIG-JNT^M)_oeznY){2t#Gu zz`kMraQ#Uhpw1@_o*?T_`n4i%r=r;Fc^Wvs;^I@;^QWMH^sUhG*1XF7A^_8Qa-ICq zgWzeaj2s6bJaWSHlaFQu)C9^my;Gtv?&a*!D#>k zU7i$XOkT;n?7T9drm#VtXzi*wC51lPqJlHZCA)&2~G7xrsIgS>6d+RXP>0M za+NTgrWG3Hv0kq@q9baBukVBYx|nnCGsu6+X_3MKzSMu_VYc(qO2wnLD`9%<+Fv!N zsA9EgCQ}66_s1t|^;z%~52jRi+yh8#P{U6uc#tnmrm61B^UW(+q%{Uvryd_pId`#c z_gz6HqDtRd?K&1D?jCS%fA8P!E{DbovkjN5MTUk}D>`*T$*%g$XOqe=X|$e%e)7?M zo9a~cdVO48GTEi^*ifx$7~#<+&t2zba{|q0PBx`8O1F)&Jw|qycbk1wCx)ShoMwv!=XuAkx$e%#7BYhc zKh1t|nc_^fELHys_a0fSJn=N=e1R@|o15azir%f28D@Sw)59${?}+wm*RMQ1iLI&n^iu zTko3vGEl>fMW=+FKK1?M11Gc)Z-q_jvNltWdq{m$9=U}Y9$T`pFpPkVnN6pt0f)4{ z7;0WC7ww>&Zw2p-9(*(wB0`O$YDLb94{GELd#g)ltVUeP)Xnu`=+zFr0f2CsJ78-h z=4kcP{m=CUE?{HkDZCIx^VcX0 zs26pPCSB3H_y4f6g#|Z~eNRAbKys9`!1}3+&{M^j2U-?y63DRc0>*T?g@rfDQLwRk zzPwjJ<%Kc7tc68BXYWM!=$bb$#O#LGt4pdZo&=@eINUmK0b|dLC%+zi2N1)xDqfZ9 zy7h2oh-z5_0Uly}$+k9zxDrFaF3Ivh+32HOC>dk%I2Eq~0CjS`_M!gQ2oi+$gbrH> z2oPoJJ3V<6B^b`*x}o8NLVJ6qu?996)7n_5NT^V`M(RMhdJXs#?jhH{4fs+IhXwVf zg2-f!%|@);whk8MB!-R(8;#i@EePI;N^47a8`)gNgiV1v=xraQ;#+~*Q%)sb%RMWP za_Hijq4rJJ{XI@&>PRElZNt@heXzbdFQt%DSm7XbaX|n=FoY@*>_6VObed!-WbPTa zshd~dF}bq0#bx!K@i2}TL3MDFX#3Qwaetddt9E5bd{IM>cSMzR1eK&7QROqT`q0O^TTzFU&S*CPthnI7;SKs|ugf@8=pLzoYkI@!$+Zl>Q5Exr@=n|l#Bp)mo;DEI zS`$dR8sT%W%dJ)Koa}kDt1<8`AH!P`l+s(v$FGfN%vSq!p*PWtxn@E*RXbeMN9eRt zXk#jVGv>gmX66u4_MK2Sbhb`lJinz#Sck8zf6;)t-g(WzX2h~E)TAr6!x1WAoX>Fj z#5!RD-7^*sl<_jU`n|wKj}!A&Fh67F1hJ7-uo6>Rs10@>qn~^^Os*PfH&w1Rwq`OA zmXiqzOqdK=6MPUv9BFbCYo8JAU;YGvCt`uLWW3RuneEwX*FXD`oS!DB`u_2o2PV8Pv>eOP|3E9i7yHA zP?_e!yp41qYG`xmg{kawIafbJlC&STCHh+IJNJ+XtUxtMYM}X2AXQq?1rJoa?YX^d z_INip)daOZ**m;8Q5o6ne^sWga%!I;k69ZiXt@#0YWXjjwJi)_L}?%|Lh{!Em-Jd2 zZ-n2-seh|jsr^c!M@aFhe497#Zgq7i8LzTU-PYBe?MW1zTlAI3M4o_wp_+=#(C6@1 z@03`)$J51N#w%4)MbtTsFr-&Ov$gFSY%1r2RN;-oK+HoScAcuirxH?Q^$SO}7SZ{S&9_4{ zqP>CQ*WznK&?o2@nAWT>zO&TV?|F>pH?FQNIsy{}wtsYi;)=D4Gz-2g4h^kIvd|T- z&1>k}*?9noisyv!yAv9X)ZHwKRLII<*9CAD6!==DpI!C%*@0$s2JgcRII z=DE0^3Z&<>rZqO*0aQBY*Q9zv#kOEAkJNLw9xowtF0Y#bFARiN@LfA=#3tHCGyG`PR@xCB%sTq{g^mzdWgC-4+ zfjkQiyTF0|8mt;rKJne{G+|y}(4+GTYLel`4K4pH$mv!tFX(FMTFexB6%7`&q}FOJ zA=^Ag?m)p#_QglQHRvgOJGe5GH>+#Q{%ZIc|94A}qZj;@8uhR`L|+d!EKx`PLam5@ zIA%f|m_?p|I2xrT)QQfN?NSszwF-PVB|JRHkJ=b+-4YsV%A}BSwt1~J3JijKRidf` zirmnP?J<3R9da~t&BLiN z)qh4XNy7)f+IemKYaH4FOQ75mm|O8Ak<+SRqgBP6EW+n3ybV91dPoxJMPUgfhsvL| zEgx#7r&=D?em1g(L7%P_3MZ0z4=-q89qzCbuiU*MLVb!c0wTr#mVBS{8n-?Fw=I7u6Le-d z9qY+Zepo%lP#YdTN_}~5AN|0()w^2XiQUrrO}&&$Zf8RMNd$>;?z8VjKJ8_XggeHM z%sRu9g9-^>gpZR}goP=Ov~;LHs>^?xvbO&tl+~6!d|u=DwF4xtY%AU^!U*dfXLrG{ z?^@0gcf-MJv|%jR$0lYz_Vj^$^)Xu)+mY4?oL@Y|N6r6@clE&Cen|)gqGS6A4fK3p%{MK z9_z(0aab24^dau++t4AN;%p`J=-J(#sVB~IltP4ddOZM_U0=hPTf-Ym!w8lxu^R z%et~1c2Xuiy7`J;wm^JtKF};R9LR^o`NF>d?lTT?IU&&@UkqsX zQ{oe@L}2a>qTqugc$vd9ZgfL$@ogktqJp~do-POkJd$VGffF&lHd(JhT5f$7qFc@t zodLpz9fXk#{6~aTLf-?(tF0&Q?0}Tx~R)iuNir#$o<3 zbfebCTi-Hzk{~zPRE*Zi0(RD^*uN89DyLXJVmbJ*yt;}Bej^E0)uSJ4h~=}(LCfuD za%ASZ(dacWc)0y;ReRVX1Ui0o7aSB(J`rn;9d*kuY#^I1OYU6~PejTolMe6LJsk9k zhK8Hxk@Rw~N!Ho;6gZcdMe>BMOmubQbTjk2T67oBqNf-wi_9=vh{)zb^=;XFDaL)k z+aEg_V>b6RGsF4xnK8YRxifrCooLj;sK_$G3?H$6z`3&{2PQbK*K<~!VC1~U6|;@s zA}@>%RmWh{D=l;bua!qT{8)OXQ=q$;Ug=?iU|`AWm@HM!*EQ$ZGk#{?>!l?^ZFd%B z<92B0_2bBXL7Asb?|q_QmD!gMp`jV^K_=psnP0g|@EAf>Wp#trdZMB?havG#Q;AXc zGMLbMvP$oVI_L&i*lTAvHqb*XB;=y3@cGv;{eZg1OZD%_epHG>_WH{4fGHJ~(XPGE zL|D>`bt`4qz;id3ZPn(j2Wm>)bh#Fk`|XxfeNI`aypBr?b*kZRpCuVs_`iK~zU?)a zW3B|P6g)b{dyEbhp3A^vivj?h5|!ewcG)y;Nsd_us`wmFoz4LwG%AZuN^=CxeFl2z zA;fJl)K^%>V&ZOt@+?AUWM&p!GN+9fGz*z9#V0*;>932L!5G*loOS$E@jKNz zI`PPKW|j2_716>_l{Srz_1^B@YU*TVS0AJVYRnG+;K?0sN!41cYTn_r$kN$FC6)I+ zks5qx=e*dn#&7ufA0$=RTyKJ_XooT@ z?s)bV@$uSt_J^U8{K?T&dos)sF0*yrojXgs@wXp6a+s{K%vRih_x6>WrM~eligvQC zu}MlY4;et=PCB~qa5D|xb+E{sD(jsos8tFLqWyUkWr)X6RNOu%{7}yh++U>Lgg*rjuTWe&` z-gh0Nw_LZMYoYptZ<1*}i-_eDvK0um=Xo?%o3DD%eX1r@b^1<=O1nNIpgOd>>~w@s zqkV)hh95N?Xnc+QXvqPea7cnRUZTmj}ZROWsFrlZfm z(NI~oEFN;7{QB=@6S2Acx*?_nRqAjxB`o$t|YKfDY6QKWY5sLyPg;Mzv3> ziHJwKN0zDA)**_$RD$D$BdBl2p~7s}2q|B$sZ)266%McDY3K29`+X^SDD|qp5svlv z9XSEuB?z~VcCp7gTTjSTsF66ge)}{~r^Z4|u7P?M>C-)fT{~d~Wg?nttxYr<(cepQ z>WWkpWNizjf{u4|kwKX!_3P$pR=z*alq(VZlF!GxG38^jq9_a`hIF2?7*UnNBijZC zi-K#S=gbgYcny{GBUCl_HGMY6+Yh&WY5cG07}#wF=CyN;=3g(8_is)q0-)%&;rzt% z*^|Ij*mhLp{f==b#M3L~c_XAxmUst?+S;hR4dIq4M~jP2(adUdlkaUmz=2!+ELN0#;{gf)t9bvE2eW7Al1C2h9?5Y|R>)ub&X+9B&&E_xNL?bN zt38%KpREginAevTth&6k|M2-LRogNAuGe$q`n91Vv*k zcNogvx>UXag=D`Nv`}8mh*35cq2;Zy%O%&fTC4817Z8I~J%su(h1FX?hU<25x|d02 zKMz}>Y~emZ6+zMe!`ORdic+Ol z=^g1Mgn+2@UIGaakWPRA0RjY)Gg%3?E6uBT5&wR!l;~sa>9KsnR$qB_W zh168w!2WvM@qZ*oz~yHXo=M8WQTGitWfj+|zhz1Y{G+wVv5>>MT_0I{3KVRHMtZ!3 z!tmXAS=qfA#yn$W-6!9f3yb6AGalQEIcB>Xm(zz>883<^J_q;olbI8|ey;hd3SLzH zm#?msAlI`k&6JB~Mrs^QJisbs2D~rpo*(yD=RLoHHA7d2C$~aTF;*IAzxjD1A}*M3 zW$(E6bSY9iM>ouz_7>SQZETt1*R~&}&ZFh+LF7Z{_AKxHf_^JYsJ3BJo<4?Cba;m> z`LyE-e`cAs-b>m7{TrC_ysmO}IZoO=k{Hboixe;8hN^&ocS9AY3xl`~UQXxh_FT|! zUp3sqOWaXonuQI7FOGQ-GoO%F?>qfC=`ocRJCju2Bifv_+aqUOP-iZ70)jodE3<5) zCQ*d8fmZ)izJk!cU5#e?Rjwo8yVG>ykpij*wUh3>H#%>u&UkHZC*Vj4Yfm zR87~0i|iB-E)?bZOg7T&cI20>jv9Ne9|wGmy9Y+3vFUORmzS~y8=fiZ5S%kQnQIdP z+WA`BBjX}omdlj|eL zmy9vbmU~OOMG9f6ZJIk`nG1IgFz7ean$DaDz4ypj9>eG-u2~<0yqv9Pt#u<46f!b3 zh)Ws`uWNvtn`n;FOpP|uZCpW!bLp#n`h%&DFSNTdwim7n#n4jiVr5Xa_iwyH2im#6 z34J8ft9s|BR&4M4j~dQT8Hugp@Q0DHgqXu#-lyD&(&;S2)p7Dzq#vc&SftA0I>V^o zuY7X^JoybA-Pz+;M_RCg=YShNw8CycQZD$_pK^IHXe-JSzy`bIUdu3f2q@%F&q}lX zjf&yF02@%vPty5oG~N9WDSkp~A1~onv`-%XK`PTaHVoJ2c}dE+G0EcqaEG?(Fd44W z(qtL2c(nfP3kCDMyu9o?wy^@G=04sJ>ph0$F#XT1TvtM|_R$t8dvc3lP+iO(@@bLV z%awovqA}V?w?yw&Sg7xqtq=$jM+<5D6U$T4j5URZsMko3EPn|GlsS5DYsF-)U<&On zcanmMegKfsTYzY>CgGAC+pS(;McGB;><^Yrk{lnG*p`@4;h}gjuWq&y{~^HC>3Rzf zW0Sd4Jpq6674TP16_(#gwPx)tK_T1m=ehu7} z=p&L(A1vLFoO9?oz^Ti{uh}OK7aaoq_iY!>JI|%&9Ng6J!c-0wX+GKfx>6}NvBtbN zBq;cewxXCk?^Zj#7>Z++rEF@Zeuc78P~c~nJbXR`z87o_FNmq z18a)q<~1ECr|z-oEd?E1benb=-5-8a;6ykp;dpy|liw(>P>V3|JV6E)1Q;K4RVe*J zFNbW)mmY*q{9N*d?1zi$c6Mu}X(_41CwWd1FA?m~Oec4O{dAJL8qA)KE{SAmXc3nY zr6M7I33M3d1!&sockK8qxQyHzaiPb8H z4<9ny`o8_{PL`TY)hnl8cJbf8CVTlUR6Quaq;BIG4-?DM)^q(2dmq-PyDXl+JAGM2 z#JYstxwb6ga{o}357sr+>(O8#kC?G9vS+340gje9C-KlcqlcYy+hS^lN6vgyJ+R3Y zYp-oc8s4P8T5-|wfe5U?`MzyEe`2k%SJzPY^Rg1>Cn8qA&rEpXGl=N?ChK%LL5WI{ zVJ3xz`IIWwVnnxv8_cWqeji!g$*HUdZVPmir*}>-&RwPOXZI5F40FDTieyoSRtg90 z-m~c=b%$l{E5{g1E9#7|C7#+}O{(ZdMt+MG7L zzXV#1g2y(&spUaJ7JWkgDRF7i2pE*O#ro;tdH3;>G!Ie5EPP&!I{>ooT%tK3cQ7M8 zJ@7Y~jNJVPrS()@wa4xZaJS{V?r#71V7^;r0KI@}%S)g*( z5K2ua863(=V+9lat*IdVuXrC4hxm0+QiVEp$9rIg6EP6a-OG%phuWh{>NnF*eXZRt zuzn|k^Y97)BzpU!C}_P%6U@0(z~=aK2IBU@gi&#Ir|_7i7(=4$_QEXHv@-1>RK5r{Mg}{zfQh&q<4qpnerTW0waLf z)F76ZnXml0K^j#ZE^^c2L^J41V9Kp#H)D<6C*I;J!Fd-hlC%Y9l3XZM&ZaT%HqC%e z^ed;smfPrC?SE+DAhC3F0OEG-?l0D^RYC&^9wW%L<~nwU_Y`qcs7K#F?o3~59=;ek zydtDbI`s08eyv@OmfxAmA=7IRtDf!dO>L*2+Q10j5iY(tH;{X2uf3T(Z&(uTUHR^@ z)|We_ut$N&Zu41^hS+LsGOj2>#ElAOL3YTGi*P3n3%M)6Z;FPYa7m_4Lu=qjs?s_G zWM%L}#rXamU#5;S)wm`Q)AjcqDt5PGs3fgd> zGhEbhCe3GkRu?!f8Zb*+rq%EN4k$H_ZmJgf)WKG)zrQT_{d?vDuiq?Y z!iVa!IDt&8fTGp1RZ>i~-zNh?slkl9XWgU6kK)eTe2#iOol^)xashydM3q=D1WtIM z$93d49T~k3W`XII%jo?NVd~-8su{Bt+bf)4r}fr?fF!_22^ybA(Dy>D5B7JdZ8F1g z#@-YOMnAx8x(tLR(x>RpMIAzC#r~lrj3Zitb}+?cpfR~y9e1JxxltC6tM{cw&*}8) zDMVpn1R*!X#+__p?q=w)*o|jbE)HbFYClb9q2(r}~HZ_?{s%^tigN0pYWzo$rB6v-jhr*dyM% zyB1t$lKf)NK`cq%d5BB`X&HZC>v^;naVAK-{YgM-mNFiQ?hK_k2;VUB&x|sWyqwa9h71I84?x zmo!3Y=Blvi>4OVsS5;_Len+~}^Vq3wn-+NK+4RX1`atgD(yztH2>AC2)0Ba#+Z`V= z7B%ivRAlC`3*m+TFmzL~X%mmQk(apL5m6L+8nqp|nAbAXx~-tWC9S2F$zH8X{3I`b z(v<1(5P+!K(KeQX8%a-SbfrWX)-~Q4p358C&d%;iP-eiBa7)8Xaw5PDY?^s7#lYWe z7y&<-B*k0S!-cVb97v`tno?Du;q99mnWVDH_lD z@7;T`y*h4l7UUGj;16PVk24>Ko%NMcd)=jna^dOTa}%=L<8&`7^3^?6rSsCYX--RH zF5~cmvl<|y_h2^4?St{$|7bG49&Q74XtmiJgwToasJHL~a>6cl!MHRa3cQN5OS~mt z)O4b+`_xo{xa9U&h%Y~f!H|X#c73{KcGZgz4QLbz`3WqcL&4)3g=x_`$A|sCZNu#L z^c6C7^=}N(3yMSNHVN~9W@z(j$EV=o{3g!V%^olZyJ=0!?+%=af4>yMqD?6ww0_VQIGsX^)M(9AwaZ!po*tMfrM$sEUYxJ`INwGx2S4m_0`GXbnA`S-0RnTK+!IHNn0T^Upqe-**Msn|unqs*N2zL4gq*Y69_=e|Gr9x5f3k`1&+$rg2kK&vuK={i^GWeO20ga-Txj<4tkd`w zF7^c62PITRCu<^{t5+Ul^9o)u`wn%`bx(E*?w8$4mjTbv9H=Cr4XLDwsNSYk$bd=yaQbSxvW|~gfSF?WL zVK-gl=YIO@sc)mkn*^#{H2u7-m_MpbMoOuqBs3feEoSOsv;D~Eh$e4b;9AfP^j5yI zvuz;k7QDH;_eYdO4Nlv{3=4^QHZ*aX6lWxhe$pS-Cff0n=vKScdHp=~D5Y_t&ZiRZ zJ@93<;mimO`(hy652wbz6?}48crc~`mXXY%)d#SXwdbF6ZI1dwC>ya(VMLRMO|umP zYC`@ff`o5%&1S!H*Ew<$Y*_F9z%F0WgCu^vO_ciP32)mzlntPSr#et01GYhwf{r#hTX>I%l)gR3}ZF6U4W?J7* zH7B2mRZ_W4AGsn-oc5!kRe7cN?AgaZxRmNT+7|^L~zZYfA%>K zpP6Mjl5f&#Zoy{Vou zR_>4*7=sBOora^i{qabOGIr1qa?YN9p)f1lT+z|k%OF^lOv_4 z_qu8wayMZC7L|O|Vc!`CrA_KeY|?o6+6MSp2=pOB86L?ZLp!JPGd~c1(*Zg{vQ)k_T8Y zS=n?G&Fh%?Jjl))q!G4s+zl#V{4ICFwX3}BmuWmhB{Xpbtrq6xlyEycGsqfa3lkX% zHpAsOXSxhX@?a|-JQFL4%6|xX-2e1kLejYQ{SzFwm}?a{g-BNy9e?vg_I|ZG%6V(0 zIU?|cXkq|2@fBE>KfEc8gCDND(zqX#PVAY0cL&l(v$BvHfM@r1{rnmGN+Z5*cC zitLQLr_e1&^_CderAY@_=LkFD^DpuGWm-BqA1UKMVzSTdBUkJ27^6w3b21UQgLdM6 zY)V!RHR9&V%1NRF!`#me4R7`|tcejxb2wQC-$&re@v%?}`)`YszuTJl|Dyk~ofz_& zz-{pOkFX74Eh zOJV<1f07zRFgWDgvyzS=s=I9!sxCLd`x_H)XM4n#VWjT0Q+8N{woRG?e2O~~wZK^ZU@qLmbdaH1Y0q!0)wTCn(L-!rsX3K_ z&r>z%tkkb#2iuDSiD@|b6fV8~SKGmW#5|6Edx~);SZ@C-(M0psOq9Cpy-_&K3}hi< zA6smmsQu&5f#&P+%_ny@Yb(JPkjQ3$;1cz1IW?XbleZ#0vDT*PtLF`W~9V6GL z*cJ{S;U#;1{rZ>1WXiepiQ+8Ln%@Pni3+Ioxy0Ayanh9eCuZMw)?c`-*KzF21DXQc zmg|ZKZ6TI8G(QFuhd2BknozD%2xD4flNZ|!wGTF76IJ_;9bd1-u9MvAg1c5c3o)mI^Z1L?=SBrmoDNA zSf1{WcHttLbPf4w1!^B&ym&E@n)#6J!DkT!G&fSp^nKnnX@^(2Lxb#DeiZq5O6w$ zzxZeFL3T>u=~cOiNH85cZy9|w!EAT3Dewi!sM>~p_C3+8@>dedoWYKN)}8y-;J4Qb zib9IWZ4CZ3qG*2bD!oYNBW^#Ki*d!{BmUdmuL8XSg{YXlm&Xr8gZIDgKkL&q`3P^L zOs+Z+Ke~Hyza&_OGOs&YzLz&V`S#pd`{GrrUcxVw?5H6^j6CG`#_N-Rtnp}JOFqI` zgn@D00P8-P_Q2d8NNFlX?0nOyJXU|lj9!6d5Q zQ}V^D$C4r)eI@$E~vw#j{5v~ ze}UmzNE(jrgXN(}^eT;y{UT90)nreamzQXv3xz%cWv~5fdLu`W%2So_nQK;&aZ6Ox z`r-Syz}+1&Bu9Z}_;%(ZN1gsxA2v+Xhr9pw0{D0FsnDUJiqYxgnJd=eJqcT(-c$7R zKYr?2AO4lWhv_?Yn~SS`xL(?WQsNs;lH0jn_u1-SXJ4M4e&iMIft$cY4Sn&NfJSC4k|Nfx*5;n-7v-@{7>Yyme zxqFm9WlOjG;Q#!s*d&}DQFd~kWMJP9|LaHmi10f_K{~6kZM`oBs=Vdn`+86l-{syX zW2D6=8g-7zzFARMNS1-{11Vyc)sfOw=RQGu2O;XxJ(7>)6DKvvTQwdbe58d%`lCyI z83rM2axo_u&h8DF*i_->PVVsw3dVwQK4HM1!MoNgf&L0#IH+~C9TV|oW%v826#Q6i z`HZ-~Ez)yqqc}#yX85VLcJyJWFUXp)9=02=)1K>$zeOeEsGW5Hj%@?EIWvXj-5(dA z{+BOb-oV(Dw6#a`nt=$){z@BNpdkH}d_1#$4(tW0UYfTaZwm zj)s9Xk6KXQ{c@ynl%|@*&&`6StCMbkmUn<-v;m94#-*Y6oG}xDuNBN2iCcOL!oN74+D`ZDhq$_?e{lA z*u}&ONz!P1>kp_RT8QrHHjj@P5jrO&_w;wqMVyw*ux%sjY6@@js*LLW$Ga&cBd*{E zi^Qe46k45wP~9BiXWA*4J7o_6`ZD`Dgt-L*X6MLd z*h{dh9v|k*De$G0+MS8QFB{P6irlUK>a>)XlkmzID#frdFloro$;r8aB>1Qsd#@`( z8Qmyj&OOnn{aL~O5{O*2fcqQ>w~ggESa_nL&myTXCxSLKIhqkz9Wq-+CrSOygvuqg z91|H)r>^dFLE+^S#Vf!?su;Pgze_?<*{hOS@C}bc*_g|^_IHW1A9;%%N)&P*%TafG z6d`(gdS7+Kyo~hq6BcSGTn1D(HaY}@rBf1Q)_*WMwQ;s`@NPBNDHrZmaXWbn2}9S`(c-7==*!Qho~X%t z(Q;7xpSVPQx;M_*yr|$*;*y8ad>v{~W*YXTXoSN>_{u*TR<0)<@kdRW{?0W<+3swW z_{xNOKGOZK)0c*%7k7NguhTAScT(Jy#CSANm_Al8hM`B!9CHS_?j(r;Q`(`< zOeG~4)w>`*R3xpc;V@w>?hIn9(yAMlunsU1!RT|cJ82Uj$ZW2U_H?y(aGcUiO>dS(H%@x-@WM| zR{L;J}w@ki-JdA6k)sD=LsWj}g0RLi)vf3Y8uhQLXJqQ03Wqo( z|K2Ztih_nkU7MdB*2{_y@=6lyCnaW>P5TfZ(w%pEO87&;*Q3mZ<;sr9?K~f$0;+92$DO*y7aR)j9u~i+_x>#jT4uuYL2Q zSoJ~|e^qANH#>*5Wd^65e#ue)973M-Il&?24kX zvJ^F7h#V#krju4;8#X!n{;Rh^%e7!3C zmQM!h$Bz$;X7k<2Tq6c0slfZ`pkmC2G;ZG40NJ`$YG3z#7+-I`P?g2AvMdV-m!=l8 zpc}e}en4mg0mXG&Ju)Oi`CbBu0`07nBX$ZYma`Z1tyJSy8_sPWq?6t?oYVIz?J4<$ znb)(CNS#cB3_$1gFT*d5I;Y*6&-1UG(9qOg5!gOJ-HUX7F==tbEz5$-=8YipI+>?z zLkgz+{2E=7}##*JN0;{g6(R?+Y5W zafMW0%IOzG0#aAh4l}c=9QYMHQ)@Rp z3t>ww+B++-{>IUBa{IjuTtl{~iL@RG=ebtUI*t1rHbloFt&ePhiTL%B2+>;k7zLwd z^~->vyRxZ6p9)Un(B|R~M)iuW84&Ur>p?tP9zsX9ykZJIHg<1?ggQmoD0tEHhYeJ9IHuMO;M_*z11C1{c^eM6S z*qI9M@{$jXTOq#;Z0eCBNS7N2n%N=q*7gUqTvLqd$~|#b&z&7c0nKL2efqO~WrLeV zSHk5rc;68y>tqRYLgfNEhaY_EdEFe$d_4Vy7NMyCcs1qq*Gt|tMWdc660+Q{a3@@& z<7YDqFSXV7QrU7PydOPF?yOVfAcO}Z+EwW$Ce*5MKZJ2q_K{MX{7s~Fp{=iZJOY&z z5`6sF!8Modq6eLaAOOIg7MG8madRxA!}gOuMwIP}rD7DBcQQ`VJV1Hw|K3GpzA^fm zDK#2UuD*mfKROIIP3?o6HlMG>3jH&WblCBWc%2u2`@Ql$M=5-cTb0(uu=v2RQzNj& zxhO=d2cor za&{LEhL4c8M|Py5__yfdEwid>SdpW3Dx|Hlr`%mGIOa4cMlBh(YS{R&%}PM|j0c@>4mG55h`FJ*JIVdeWwTa+R; zi1(CQTADG30k~2H=$QnBLsUK3S18BMzck6C$P7wg5Ms^~0w zQsv}oKIk>fr+BH{EjRxK%4FXahM4ME%jIR+dQAqEbGpK<*ehK>#xkCqL%g)eC&YpY zu^PVq`{$~+O>$|Epw#Di;(L$Y62=ZJPj$%Ve*eAuOX`|g3OS(yVRS^?a7awj|KZAt?zWt?@8gzI<(g#o zHDdlI*+f(+5Rkj{ty^H(-L>{hk<-x+xbd3^h>=Ob-{xBUUpLyn?8oG<`e)wV()wFB z%JUj&S%LmS&Ie5%m?rk}iSU#hwFrTXF#1v4FP$qzhHti4#$|U#CSav;x?bvUP%qia z`dZ>7oU^631~s{+3%b+{aore&-jWpR2kw-sF*n4Op`h8tx^}VnrVTo_hulWevMr-+ zp8vN5hLLN!gqD~;A`Hd4_7T=YcLrpz5TiBo50tTaS@bKCNUIh>A`zx5)T)*stORB{ zI=6>mu(@~(I@6M26;uUJ>;a0lm_u;yW9B8_Dg#$_qTD%Gie1)% zGOb4#+lHJ_07<*D`e7(rp=+S`0G(OvBg`vTS{Usp=A^XVT>1jX|dy zKTC^M=Cn&A_>2(yO_G)yq>|1wo6*>vyL>HttC}j97W&UpzfKB*H{G3e_`e=DK*S8M zpB*@m)Xi-9kn;>IJu+QP>|UiYG9Wg-dYNu^xryq5*5P1D7ReK~6(^%6&1t`SisAGS z-v?I~F?MIk^nYQNZDsibXo#=T59uQhW&|BlVI$G6oDbvmGy0$1UOvdd|lgnqj{oo82nZ5Rv=lF zscHXoE@4Gfi}}9kdrKXYt}k{Spe0s>R3&+Dv^gzi#eOt*TLpE!DAXFG<=S+DUDWoa zLo~wh#fuB!L!N!a!m0!Ggk*PXem3-hxEA_c0)KvzFIiIv(d_QuO=IAjBox`_(z1sioVt=H{dw9fW=2$${(P82d zus?vTg~?3~58XnZt61=P@BlC&Zi_b+XV}-M$|829)Lan?K~t}q;Ch=OteFG}CM6Q1 zT1;nC^KA))jn^`_iVDa0QreR_8#g>NwxrwYfCJJC<)=AjrexNRk0D(QXY5@srMdpK?G? z_?y9+?d~giA4IO3(PyG8{Pzd$!$z~DI$12dVj`%krVN6D_xCZ|u-w6PUn$Xi@W8() z2AGW_h+AFmb}KT&U(lruCRmsl&b!DoD7|>>8}bo>ljq z#Qe2%-XZ`F6>s(~6l!l{M;;Puak+$snoN&Qh67oZBxYrxLZuky3?ZbC%Y` zFVPw~#=Lh#FSk{p_@XJeEek8ch=_JwvPf%v#b-Lo72i5#*M^#BXPJ)E(GcKfpTR^U z5OvU#JxGa97wkGxI6GPF#ItiDeD`5}t1sF{QWz;B^4guPblU=4g| zR#DsV0X3@nP<2R#p~QNB%^HBrZ}>=vr)SY+~d5-d-t?R=QQ1&s1hv^rWoH zq?>!DFhm1byGFIpL(}+)+k6T$k`A;6p@uGfie+vk1mY+2eV^HAUFo8u7Jwe_?y*`s zKhxt&DF?rjJ6%rTE_9;a&&~ys@Y);rRRNIO1HWUM9kW1E#qbk#kGcU>k({Yqct2IAIy58U_Ho59q0di%i-cr5@PS= zqGMCIXEj=WVy~SNpO|T_Ub{)b{IMwfgQTD^v8|M>sdC-{%|mO}p+L;CoKT}$bBDSS zp|T=+fnG_mylpa)yXc|rtWCAp3EfX$uFV6tIs+@G-w3g679%~+%ge0DdOM}F6rN;x1zitIE zR#&@&6ZL?7MU8fs4x#IQA<4qn*f_A|aFvMV5)(rwlU9{!WmpaBNonTML!HII%Q1V* zV?LSd`pdw<9MwcFV1s0{d?UdH@w6EG`)A0w31A~)lS$^gMirJ31`r%rra6xd4Ueh8 ze)j$O^y9&J`y8wm_*lS&0I*?o#xu6D&8i|DyI*)TX8;6KT5As?bb%R!cduP>6H2gQ zCmq4IsG>Ql=qYU%3s^oZ$LvBT0$CkoD@G)i=;;DWBUgQjmA>8&n^>)Yg<|+>s%`|rUT>Dg-C(>zt zC=g_Swxur@nf*;_++Q~We*&%n$4~AvTzUQ1Xpi?o{{4|}Mr)d4r=Z_6+m2Cxjc$F= zb`xIEEkRvDa%?uFUGJSZ4i@ZYlUcg$0BDQsywQ-hlP%omv(&J4)w9r@_ot#_w(?I- z&h=rDYPqQZ-;j_HRdY24x++Cl;Yu0K3O{q{O3q06k$3b=n7Mn2XPNKcz8=0lpe|>( zHj+>|6ayF&quuRbzcjY1nkXuAHLjqko(bYzD!tKq>y6NF>*rt1`zmKsyjCN?Jc_t_ zw-S3ytE%yPz;XH|8Eeo*dhf5d*e9N1aFlAj02j#<(Uky68oq_1nmwl@3>iVUT*Gr1{Vqd|1Lj=G650J4DgMN zAovixs-E3Em2%i|3r8ER!CCdU&CTo=F!%=Zo)kbtv$wOXE;2{@z%LKU_JmeVCx;Qt z0^lofN%JQh``Cm4_?EO+5}w+0jA6}aBU;RCh%^zwk4h9|@kVTg(VK0BO+;ns?qkb@ z#fyr?`HpkUv{kh*k*kpVsyuPY^Y%GSC>hixinA{0CKz4nXGy7TA8pLrKSELL=jD^e zz=Eh{w>%mlW&Y1HZVdf*$iLFDBIf@A!520zUG6YNcdtaIMBK=M!}C9}`S8&+Udj2a zE;8vhvrsx$hu=bzjOYFUaT6Lct zjH6C)JZDwPSQ$=%4^#$x|L(q$zNni!i33|edO!j)s-hRH?;V$?_2uy0cXX3*e_W2V z4!0>(24bE=?0)sN?^+;7D?nh!W+&qh8%!|y9k13G+Q2w{KgIo2EmZFAvH@-{hE3Lc zr1)%{_1$G8Fsy{uuLu2!0nLphq@9UPcr9Y+0rpVei#}Q%fXeO+oV@HtOsuotK<^Sk zF`2JI0@!_g7lt2hJH#LbPV`vH7tu{&oR&*l+P}SS?tBAo4ur3i%u2<;a$!7W>YT4b ztVkpIYw$Iv*n<50+aQ4V*lmdGs2L4Sk9`Iv-XwJW#EoI5NBPX5Tkul?s%A{}xS$v$ zY5xG|=>Z`^Z$x#%W1d|CAfIMnae3c!b0K{Gg6p)n(d$&m@!eh3eub`=25VDQem;tf z`&di>T*~ye^210fb*{t9C1Q~6Ec+VH%F@*@#O*5>93wppLuU+^S!wI>E|k_L>(RZO zcNz`ZjYdOO4oq&w=uSU(j z92I#{6{bpjdsZpZxu1FNa^kJ3nHQTZw_6)zyi~bC2~Y6Lo0Gnk*oLONdBrmzx^0s+ ze*gW2Tc+(_c(|r>>>Urb&>cYIteWh0PRecdi*t_*+TiklM;B%fK#p-Hz>rwCTVkHA zudu-gK}RJFxkCay&V5`mKrROA^90yzI3S- z6v&m~Me=upJre1}t$cz36v-(XPU-!s&|hlO!c|GUhWNT0B2d?QtOoC;*m0c6@OnZK z0yM)kiQuzJU4L#MbjfX@_ePqkjp4T*K`+V^pR;P;sKau6!ic`>#s;=)gguD&?GtV> zqf35PHuQT#-CjWtoduqp4Wc+6Ga4lq+)_!L^t~7*)mloeX)G$nXbLD2UF!gYfEN4g zw$EEAAECF6pvdbia0A$bU4Z^o_J{5N~sG{xsl} zqpXL`5`3u$K7*>xLAm|4=Jci#$o_98;2fbIZQfG3 zu)}~4C&St>K6k!o_HaE2 zI2m!fhZmUrWJxh$ASdk9#cL8ynU|QDI)XXiFXz1*C|f0ob}Mo$kCypaYzyXdNLaAo zzHzt>)f?AxoiGs*Eh1+gfGAVs;`623GJAnhHVK zq*|!Z@7+uYTs%eQ8K!AGxm9~a(n-i>U06)#S> zKYo~Y3N%lzD6*q#W2spVvlsL3@L6sgc)7&^0&&?B4sL~#y@pH|dUo+?JC1_gvN8?s zHAfVq)~JyyVd56jb63dsSN^%aYb~t%%DvDE&o}q?t>rV#k5?+vS_TGM!jkE_8g_n% zaE13)eIC*(DL03~XCj`ADFDm^kzVp#QD`v5rC$lAW5qRmxXBt# zkX90dSgqqt+XIxG1^{tN-FI6x)GTQ#Crx-k24@l4N-E{Of};mQ5fZ zgvMo99VuxD2%!t10BrN#gKtfECnqPByUR=sYsX0#uvHvqNMW!qp~+@ZS?Nl*#R?C> zD#-L=s;a8dEgZgMroqFaEOCYY-7JODo|~3^eq$Vzwcypcgd5_99-us%E%bh+DH!PQ z@7%T7eq~^LcNZXhmIW$vMhv&@PfNMwT-9j&`LOrBwr#}_)kWo+NI7AL+DaxL zdu?s)`1v^5o`i}=7xuCGf$$>T#WJ5rA16q8+`VkXC$HUyJVtf8?K<(*>qINuyoYHVaO(yew?#H(4s3&a)k_|LMXF#csPV z=^%ajUOi;^Uh$xDOVoZUn&s|QrM||WPnCu36jq4)#GY7ygvFhy`#Nm%d;wes{3!A` z;r6Xt&32J-BhtR7&%$Jtl$6p(Js_ToS)R0)R%$I9?Q8D8YCIAz22C0BCU#9F#QAk` zr?`JV_RV!+(lmMVQkN)D)g;V{8TKvzV0l%b=W2JVThl+lQ+?mkygyT^49wdg`DHB~#qC3@-&2}_oLR_+SfT76s z%E}W5OCMH%xPVPSVj(5Ne4C!T^uHR!3_ zNiFG3s7rsT9RD`${?Dg>QZW23sNnOY8bzHFWA+AZ-j~jke$)_#qBMldh5sToy!j(M zA>m?kh6v@kCTi`cr`Sa4IMbI>_oj4DtjKsfi{o$efM>aqj+k9mHTQ|wqWhN3Tn2`_ z2!zUY`>2(>AKAC0%|3pjv>Cmyz2GyTNOg-qyvXHCI(4cJvw!Q>MNRaRaY`DWU7arE2kK%klF`E;43vmoBk z+1WW`$yiD0Lnmmfs_e(_0M3$H72e*Q!=xoNS}j>pq^1Or3H89xzH8tPCb70OY4gsj zKr!nTi2YUf;KhpwK1jpZicN_z@==eFc%q%cL3>Y6EEoiz85^e@a)#?%7H`s?IQxZB zP5ePXpfMhMNYmR39q5&mNsZ>!ZBG!fQHgnjoZn(GH!~Bd$kX!$M%bgjk0rz$!anZE z6AGWpF*SM1H<#jaBFt|YBV3C-P?k741{Tv*;#706mwqK>)T*R`3v`c>x{!D-8?^8= zRc`j`+4?DW#u>b}l%WQvQoI_Mp#{?5`!RY1KGDH^nK-Yc5knwa{TsBI@CsH_a_XeD z*eTlx8d2`6PmUzbE8gG#+N1oe$7SHnc#mxO28{B`sHpSmxRDbyk&j*SUH5rIBiE;* zV=`jb7tmfc*O<;k0Hi?YqNKZu48t8Urj8&;_bFDtWvh0+zoioVN7J-&*dDq1(jWON zqzjm%SU^aqE=7xLg+p`eM(8NrwM)O_09`pN23%We9`>HO+NwHCs*@$=Vh4*w)gfvn zlwq-?Nil9-j^lqGNq_Mxe+URfSU`3XEss#395pzV#7e~L0AJcr=H@h<-?Zq|!$aML zbX9QXIp;EWZmy?4f8FGPBnxVj@1#pUYq7VAyjPa!(tjW>z;eA%&tJsGA@JviUDXxb zsBJ1nP&1$RtWx~IiItWM+*rYX{m%b-7yjc_y2<$TVtkFZt9r*Ds}*&WHqW@Mbx5yAnTo z7hqz#TYk}caL!%cstFTEp zTQWj7ew2^ubC@N2m}XSkS<#3C1E!CRU40 zTv$U#=iXV!B+c{}z!a?PpR8!Pfq6KEj#5)2i-Ukd%={Y zxUbOYKdF;R^Sfz{a+Co@J92&rB(wj2R1kP8$#L`L);LJ$@Uxo9Zxg?Oyo%VTyg{!M;K`rfQ8?rw`HbczGsiT{2lp?klNM0kJvMZ#at$OEZ%CZ<( zRD7({8WzY!EC~Oj)bZc%`j4J`J3BHm;#|JWaZb-J;NHQ{%g+9FE<Hjph&eONQKKoxm13G~XYr)D^ zEMhh@w%(#{VIdOU=|b&0WE>`Ias5E>&fZ_iD<$9K9=)2Vc8Vn>BMTHS`s*0Me;w_= z|1fv)R#TDTjdKTmx$LfQxHt&z?6y&-oLN#*!sn6%{S~%friufmUvZKT1}l~Kt1XuO z_o4mE*aR-|y-N(BsXXQQmA~JvPxIofiPgaJG}Mkkh6zwQ3^>Q0lnTe&nUi&z>=Sal z_p8g_G$d1Gk6sNRqQtX*dx&U27j)~1tp2VGzOGymv*8?}?ep!8c*Nxys(QZW>reC& z_GiGdfV@r6@#Q6;md+Z2w7)!nzLZy%o#g)N0m<)SEpYnfjc?}1gg)H!P}#TrB4I44 zqGR|_yFft6A^^6*j=>{?uhzpVNPi-_3cj31q8=Qi*9l<&B=6m%e7w z|Kp< zi=@!-qQ+cIue|1CV+!SaT46iRc=5xB9UvRT0nv|t1P1nS{<`}wS^OS(1SVfQ;Gz$m z3X1vruqA8Bq~PrhwVtfdn@QYGX%~L{kHgqu zPb5PX1MgErwX?@08U@vGR+JdWo`O+!$=!oWpwl>w_8}q-m$tCds$mvG} zpLHfo+~@Zn#5HE=dU=p-B*4#8=rXj<5eN;joc;VWD@#U^FFy{Qn8o(v+NlRSFXw1y zN*77cY!9`0l15(Wx2Q>%M;FC#a(L|Jt! ztsgaNb3u0UI1P$#`TW%j>ielMVsNr~Uy-=MuF%syy9}Z6o97|H-#>c@L>V|Tou|W6 zY&XSwtnDvNG-a`r>`zk>Z^W8fW|_|1vR>6=y>+64Uvcdfu<;UAe~R|dF0=R5-kw<_ zIx8#{)!EtE@R9ScoIon!Z^Z?657S`8_kW2|-0Zg{D9ak*?3_@_cMjr6dfn=KJrp0W zVpiarI{w`_7}k8Pj?Bw!Jeh zouiY}=78QBy#91~ulXNm#QyWi%Cba=IJ-k8V`E6;(Y0eQD;nD9_qO|`E zfVh}~B>%6v-~V@!#lfG5m3+s{JoLWfh=w!rcI}M2c1i2=&%1Zf1bhT`1s zSFQvSCzWuKA1wQMuQy)ZK`lnd8hkQp3yo4!KCR15K3TdBR4Y!Q6|gP1PWQK7= z%~dSz`Cs=a)uM}(+jD9eYy}AW6LTiscdpUg9k=7H4a@s>$!-Oi8KoTAks{K8?`H8< zYjn+;O31M4x`b(*v58BVWeV@F+;8R(+?E!W64z-@pwQztQd07GlH7uv2<0U(Cj%^L z&{H`i`8T`#O%kAon)Z>)jDMf=HNGQ z54KF_4-8Bz!H)E-<~KH^y?dYVLe;NY9BZ8XEYbO02oN7pimXP76NAw!gSwZ9XxV^N zuTel5L;l4#;}mE2lcfCxW;+!&u|2w#oxMr5Q}m07&Db**{|>gK2)GAyVgJPv`p;>8 z_GPHcHxDL73)jg0jyaO4nv!^$CTS-MC_I$00Tn{I9;Roe+V{tgMQ#MB0`9jD(J>iNn>U#>U2SIy!kkMyIF#a)i<} zXQK9{yt;g#uc5&b9Zir4tZqSy3eWiAV9bsQYzAVQ1dtv*h-GxatKwJu1qNon8CnVO z_vSO_!8t(E@%hF(nz9ue+<$x{iqj^?J1~Q4jQL zx|al+(7z1&jsx$KKF2mlEv8OnFxa z?yHF^x>B;g&+&6Da>=Nxz)Z?aTstTKxH$u$6f=DYOP*uT(W7@-dM^Co8Cns~3Z;8>FJ?IIt1yNRV3 zG%flS_oY$dpW#N|_gnPOeU7kPd!3A6!)wdz_HrsiQN`Hqy$!8aNQ!87IQD_ED zKaHeaXmYymH$FaJ8#l;yNfeHI#jf!Ftqvh1t}^mrX*{u5)>SKxie?s>Uv7gp`ThNWeIDok!A2WUn+lMNkia>5Lv<14TQ^4(G0U5C zMZ<9gTCNliQ>)X84vTH?tkXAzSiXR$d_c?RyOO77Q~t+t@U_*w76bmuJ4f)0C} zgzOHYI|kqp`lR*#2^?t2D|GCu7D>fty z3p|V)-7B@-Dysde6!ZGV=V>0N8V?pXSY-EH28)Y@P2C9ol$(@|3 ze?acke{s$e&&vgFF{MM*c!nesq5B;vHvD4{$IFjo+mE{(=GGcZ1UY zEu@JOKnymAtB$sRe`1o|_`Ion>XL;wJj;Ed6;GidP`0`i; zYfrpCc23bueFb)*CP(#T|BJ`yZ*1dV4?`LU*JpBGV=?jHz53^GzT`LiW;G3N0|WK% zU9-Y7w7FoPbG>n&rGn%sV46)%&JRuAh$$^{Hsvk-CL6TlsYPc1i11K0Xy6 z|9B!$`0@jR=bvKOSG0vb#V51K(+wutX?SwwJ~)61uI8Ii5p58hioD;)+a#DFs$(d) zuz&+JU$DS|__KLmf3nvgW00}v_sx%0)C@`OeNFy^oxVGa>|8S(1RRRkY2yd2xX^AW zngIE+_XLT|DJ<373#&fa*T-TiCi&q*a0#)G>qV0*5d?d7B=WUKlkY_@MF0I^{8Lot z%CPS){)F+Y-KhRk7N*+RK=g3Y`&9}K^tH63X!%m>uf4I{>m}No+NO|&pW6*>)jp#1 zQw&oKbrO^k9^MDZ*`f^Y;#;8IaDR~aED9%u=y;PLnMjmDXZ3LQxXq{SGq1dYQdIZa z%nO|=-K(y4_A=%QU6%#@d=H0S!u!iZPo`Tb`e?-CMi1>A2Zu&yDnn0@lS+sm(Oa17 zkPi}q0=u3D-|89AvFvS$j_FdC( z=sHLOP420Wx!IEs1s{&`*$bEFqhEm#MwpQ;*T@9EIxd}arE)GU`z1$7B&oR?l~3&+ z{XL77ap>k7VXBWH_H2+^RIx_A8I`(SthPad7(vQg%twshes~yD2vG>pQrA&Pcu4pi zaoLCBXKm|n!dw4(GWV}H&20w}F_HK6yl$|d?5>^kbW*fi`Z(vOg)I|2Kp16zi8H{- z!4?;OtO!m)L!k+!0eNb`xDX7(AOAAO{y-A^tbKO`1oj_@679~9*)foyMd&N!^H~Qw# zt|eueeFboclNMxs-pXV>97k%H9cxDkcRhJMk>h%EuysP#XUoVm+gk>#E|!IO zUGDZ0Ti&llUyByd5uB;)Pe5D44?xQ_1e8QnzuM%udt7|7NpGBwj=m(G9^cCfhZdUG zG{Tfs+nB;hCIc0_*WZgJR7NLbYbN>C7be{@?odyNA^((0kzHdhpNA4Si{%={Zw6=Z z^~T;sdqq8Sm=&J|Hwd2JW<&7gJ92S+p1w}Yx(I5l=YtyD6Y1<-P7wWm@XiM;;X_(= zH$JNd6KaY)n&5wcasYc7)BWkH&f?2Ya**E$-Jp;`cVXk}JG|SXOhnJ*_>le}Sue!@ z-|b!l>Ma5XhL=pRzV0zggFO`J#kamtfZ0#HyKaEGxWQkMum_^46`MSVApBEM=b^GB z=BAvrNcM_x@S|LB@TsK^hub)4bgknU-plGWE-$=$PZWvFY?|V z*$-;!r89};#e5g5&zJ(DOaD98oZCeaV)LiI^?c=5yq`XMemCPLdWlg4UEs#Nwarp@ zb*=&12mqwt_OLkDyX-UQIctpz@AOPTuYa}g3y-%xZ>L>sBXD|jz*>0G`oedmg_;bs z>w|}5m1HS$5VH=2Kc&JCd^49O9M<-AFn;(~hVz~u6Gn zyv%B5(^NLHyy!Hp;2fLff387$5Bfgh`tUgw1suNohRO>bsP}3RPj%ZoD04g#B!qXp$k^Jh~U_-Pxr3HaW13@;O_%{?5sP3 zvtRgOe1;&-KT!QwXn!Y9}dZrWvaB z4)xY9EOxryj(Uv1zy}28mAKTu3R-#ihYH%9AW3ue*adpQ(iv=k&u4JeWSqF+Y5n^y zYy&Xi()Pd&7~gHllxv7b>!G?ZSxo(GeXSlI8Zlbn_f#xCz6R-=3;AKQi6sII#V(B!KcVC>`4Q{@{S5YzzO<0aqocG)Bq>n)m6(}VRw~#_c$^O z%G85`f--MQ7yxIRIBMP2Kb4dW2uk49{I;71$V_(zaPg>7iad2QorL}zC5R2o6JgsL z4h%)PeII854`@s%CkgjXf*52nX+t~0zzx-NCsw*Qc|dC2(qhAP)l76{=}&=!tqqk3%# zCu&ri0f@XiKx!}3Q>=0S8W!GyA05$uBDZbvc4Ia3H^|Ufd%q@_ah<8l4je1TmBDqO zrMtqAKVNJ9eRKJdk;%^-RUB$aQ%|MKV3vR`>z5 zaF@_m1^4cPokp~}CcS+LCPv;tJ`ix>cLD^tq^;u!@T4Eb+aF7j?ACe?-_x!(tiFOv zST3>pzu@&hX@9B%t_|Ck7EeoDyS~9zdXzysD0hsf<;#-9bnVb6!CLLxtbhd86G?(A_*6c} z+c;mqnBA58en2c9P&-B*$m-veM2`m2X)0WN)(S7IQ<1g0$h z(wNdr1D=s?_Pc|m|7Lu=M3?@hXyObzhgGh$v#2{MM2EmJAaMrrO&HJSk^3pD+wzuY z?ModU{qTVW85EA!=OUnEoY@a>YhNveXkv`OtJoj*8FlJrz@DS632@ zBoIhAn?oz%@QEh{D3VhiW@f7kQA+%^DRK_b{=XDO5=kK!C+w1UxVN}EoUe->zfS2< zGU$VSmzhkcMq5SX&hU`%{C3dhTQ}^a(*hU!>OB{X-g9c(dv1!IzZkq~Lm|OIld>pK ztxMEFDXt@YgZ^GF_#_2_x=@fl_G3$aGwFSw*Cri48L_lFXBy$p|mfjw!df*<}}eOJH-tT6W5dsW4)PD4ag$I8ixU!qf1QyqB~| zX=pgIfbqr(yPC^BPvCukw{jB)_qIyX73+z*4?x{3c@fu*&x4|%gNHpw7%rV+kcUe$ z|I#Jg+ZX=qwJ4K1A$Y=XK1{I5Qwuk1#bDj*84x%K9M=oyG|5stcB|O~UE9-|pBnbt zN#1xF>Fg(EP}0e70v`lyH#8hg3fm`2ddi9k z2{j)C3NEK_=+&xZe7q5qB|`0Y;qu#t5gYch*ZqbQ>p=?j4o#j%*h&NT`+Q{fk{9s* z^cD2@Y)I6^KEvI^36ORg{D!72_Xh6<@7K73M1n+XIGMNh@POtgeB*ogcY?oK(1o?f zF69K1qrIBJuX2lqS?BGx6TO9M>;^n2)Iv+Sa)yvW1$2`!s zw(z@t`7Ln8AHCkX;o<-pOOqRhfkLUfGkI5snwj};Zh(&8RcudI)$D*G7jp<0)`&c4 zcTsOqIGEH;0{&P}dQ4L%9;iU{yTt$gQ7D!eAhM0+<-ioRJ`c^wpV{pebd=Lvcac`x z9ohI9LjhY6@aQucT26kX!T+O(YlOC3aC_>p)ZxX?)o;p+zQ)u9*`Lt4H-2!IftR+! z;VC-h){iNOYTm96<=XV)QQf?NbCd0gZ@j$x&{r1@;rM9jTnh{Cba!K8W}AtsH0Jp} zfCZBG``t)=xq=sHGjU z(cjn>l_mruzfGt**Y-MFV>CO(tOe~TzCQC1IxRs>o18*E>6F{v*I0%Z6@~25kdms$ zDdZ5~+`Qn+Nb)*ipiJHR*pjWgvfJ%P~N`>0y61-=_ z@*TK2R_n%q-I-+g?7Dn2>+9EnQ3G!!g1t?yP`TZUe>Cv3ud{r9f!GJ)!JYcb8bmkn&vX1MkxbUY|F?$g`pJ5f?0MYhUVfzTCM;_a8Mei2hydfr~CRb=|O#4YOE6eJr z{Mp<)GP0la)-A?GQqrHQ`ug61KO1@*dNo^7!_Ejg-5xA87p(^) zBO^Npa#?<{$MeJc0JE@{r$E(>&Mufp?$NBMRtk(qV>(RoG-{t#vf1FxM-F)fYJ1{g z$XjmO-JRS)SFW&=(q*(v#Oy0oH#{J6}QFJH3ALQnn_?r*Eyzk;usqJ3CBCD*J` zC;Lj}ZQZsopZjZxSy9qqdi+cHFH}?_EXc=$)GM*5txWdMDom=q4yPwzUhkoRTjO2X z2B7EP-*7zTwOUHyUH5Z)PveYU(3l=!KyBL)(ePSIG093Wt~Ugdur6nrrDVETjn1Mz z2qIX-;Gqe;*^x%Ain@*U$?@EQ{C2Ml3_YaF5iG-%Y&$8Qb7VXFL=jF$m?HSfAQ3#j z?{G@M@p`%SbgOZv0G+T)nvdzpo%O?{rp@db zpR&7+*_;&(jAn;nb5UHK-ZEYVZMhW;Sq{xbj?5}$=Jjj4x|p3ei#f8xH@{J?oi2K% zT%{py9gY<^uwE_w#Dvv9fpf5P-wTT8wUq*D143bYIcHkzTXOd%Ik`+MVRanzblay4 z2yC{9692MC%y=5ZXWowYhm$>r9RE1Kh4UwK^R=(5_v|7Fh-PlCwivAutof&}6IjS} z8<5retd3Yo#_D<8z{(zPm1}MCgn&6+?m@(thF%HWOSf>?_xDfa9w^E@BGvLKLtNy* zdgs|9T{?UUd6Q1@@bE0!eF`56zVV5*td|278qhk_<)uh~#c2=g7!gizzhtF)1qShO z)gO_vLRd!+RLr*=({B^=8^%d)SxM@ReNV}gIKj7zko51I7+xzmUcZAvS(Yc;qAG5@L@Lj)GWEy!Vd4L%Yh-U?AFHAlYCb zb=xNDyQ;HD5+Y7i;Up zF2Y+`S#+7lqQ(9VlL&7$)q>%@z5RWP_Piw&Gbism#`NZ{!5{gddYBhPw1VtNu0!@z zYmS5ZJSdU!0O%O+I9IxBWMWxLNJ!{2__)gXrCFf|>kcP6aeF^@NV)S!_j9Cdu4OW- zX!dv8W3~qIoV%C?!?=iCbKJQb2`boxk6OFmqrF8<9YOnqof8$+Q_ggvyDt>vvZkR{i)Ic#QKmUnAf}Uwv3>zlR;O%qDXiM_T%9ScoWc{06|t>Z$}Fh=X|^FH z)8RVoA_@Gff|at;07$38mNoI~cq_F&k z35Zp8|6Jx4|Hqj6Mb*4`L`NS3Ou*v0UR&irvhuRqEtnUXFg)JkhmAJzEW&H9WK|!m zI2WqaPu^|X^wS^In@oi{d}O|lz)7Kd6^+ML6+z8wEw}C9;N0keAkVrsamLpqKiqts zu-l?FxcJgK-}HFb=tD^G<3DAnlBxG@o(V(dx=@oqoBMruz#~?JK+=mBe~dS>xm^@p z2yim|paSCxdq=eA?@Ir(x^E}D?_>+)t7D(jaJVmqc@?Y{>lv_%lz3~3Mf<-gQm7VJ z_*xU=?44I%vlFITluFS6)qIDzCNVeFA=tnADxYJm9(-y?(cAAf&HYSpRv_z}E`KnL zkWrxonj&48w`M2n<$7z^ZKTPB>s}u;CJXGC!UKM#l`pp+#>?6TIbY41!;Rq$(cBHY0MOwtFyzs=8 zb~<`a?@R@l2>M5JdAazeO(A33SGdv^aWxn#Mg)A4h755Lp_JXg1Vnh9arKWX@;73|k|NH`bHm!Bl&F&kZ z>kq~lvWHG0CTEVb^bLzBF%C=G*q%sq!cU!Yc2348q(aN8-Pqi&MK31qSie`}eVAjh zpE-MbR*%q8J!p^g`rA6k=tqJzS{;7*I;>mh*{)t_f`Cot@p0X)6r!ko`6c8G4hZo* z{GOadk*9j!!IGz(G%q0wEgT8zl!N9LJ(uwDLALE-F?)f;<%i}A+4NIfN#2m*F2@9r z(<_$jXRBEc*JHoheQ6*4$qpGN+qrmUYkO>TjG+}Om<98n!avYx#D7O}i`}E0j7-#W zm?n(sQGSWS_LVEVuV)WmygKy;vkO(5Y6pI#{Y58uaIF)@WE>0=S}Lwx`D4N@`8LN> z`Z)SdPBWK(e#r8wiW1|khn2FBfQC!rhD?;0M(>srLFqkzUizbq#yB_W0v~mC(&1-? zCWKIzuDg~)Rk_`mz$T&D<7+FEToPQLz>0Em>H6T_RGo69ivG-%i3*o%_v0mE!!olI zb`C#=*HIPs%pD8ZOned!xU4GA7bOjEI-}P*5AFCDliEw$BTHzH@|}JLr}(^;K#Q(B zTlVEV0#c_9raiSNbMm@+U#~8Ln}?fr-O1gZ`6534cL|ckpG$o}?L)eCEn_L(!@!8x zwJFf9`K9W2m7PxX?CxDvQBO8AWWyC$VTIG4s>q6Uc2UtMQgPaHD8w;G8`1Ua{ES9d zggh>Ke2(_`eCrlT)(m=80EqL?*j>zK7%VQ}#LF|4T@|pDq7!i;vrxB-P(A< zHTY(`K`a9)sT2g0DDOhUn;IAQQh@?v0V~wo-IVZo;p(cChBY!YY#yUKTfH3D)ufDP zLTN^VAw8-d)2P@4odp;Yo@sHdx)4bGLJ<~$G0z`svl=Z-&!GB3j|tv4dOv3Hk+xW% zgu8{Rb3f1Eo3Klc(cGS6YhA`ljf+w2+isQG9|P&SMr^Y zM_$ZpKg|OF0lf*Kr^-3|)~V068CE3;Y;5Sdc?&3F7T;DQ_fARwGPEZWs4hLN*oe)~LiFTCIQZ&*U5eM@{kcRdK|b4=GT*P_ z{EaVQp3bWSNw&2}eYM*#r_&RNY3=CDVf)Z&tsOLV5GFc#OT!elp~w4XO>X=o&&t1_ zs?fY!`-l9&Xo^o}u)qG@L_Td+rsrP{9%flDKn$0uQdIotDaDd+H4n4X^>k;l(t}?v zml>$*?vGk+g3MR@1=B`YHs_mV2&4F;n^6p zsJIf1NFkRVaeyq2TYN#>qN2(QezZASp!2v0dQf2hkhHgXLYUnn_Lb{JG{eKb#HexA zsV!kxGGCOqXLu5gDEi8kD;dB;wGnPaf)B+t1iy+)pdZPGO?&R9zY&0MxcKxOY>OOi zByind;I|q1BojtuM~E75QtrW%`bdZ?C?qc#V4nsGR`^tlZ}L;zRtxi&YfzX zLDQZV%J4X&5L$1M?V6V6)_9ZEkNb0gX%^Gls08{feuR#$oXKuzxXtoo`>0XGt}oH& zl-y;*W7_HT=@0FaBol-DUJnnC-L6w8MIH2(bTTrtt9W!0fjOBhB1yqg2g)ndd6-~! zW^{`N=09Ak3nL++VH%To0v)fvH^xQGJ%uVoYQ!I|?)fF9k1MesT9eZ!@8S>V>cSF7 z8}SIt*Zi8ms~JZkdIn$&QybWt@V4#A5tib}dcuFc@a$3+-rSD)hE_$7v`a2%t*zVHz_uVG-nqnl(dkq{K%H0u^bq!tKw)u|U z7@cjC3mUi45_S>F_ubZ~7?H6L<`L5!&pwEpTnehcKLKrkv1O*;ddYXJBiv~4?k5wK zPtX?6uu%DCpYJ6-Q7I7BGd4XpbaX2Pm5_im`nX`KmE2n*7e}5I+DOr(4dEMgZkZW_TwSkOhj~7rEDb*mkM}|u z1i6t19m>MOj>WWv^&73aA4HK08C83Wbtr=V)W%qM7gHm!(5Ka^K((JQH4h5{lSS9T z0LzQ<&zUBD9?DdMBb!C@9G<}>2!Q2s#-+K&^?sZPnQ+w#`KO%0M~VMrV&*)(@`(S z_v>hBE}FBy}CcyVsJ$^4Wk z6v$k;zrKU6`LrPs_FmkZd-5TSE8z8E80@P5VQdz}*egQ>o98is2p5fFKlH|z*vdb` z$Z8Y3QdCa0&o-cED%64x5V-`&vt8x!-lH+-p1tm0f89?FaCpz{B10}`0d{?K$c0l8 z!bwi0&W{@0WNCzd=-me9I~z_uLh}Qm-iTjdtWuU7dWU^ywvh%G^B8h71glbpz1ZGLVct9&|IMg1=&8CL=zdRXLj!2yxUAuzk# zOsh9`4?Ym>HMtr(vZq-i)`uL+Rb@%$IDOgX)HR6c5iX75XB|#HRB~UF=V5#_>d-uY z135YnWTME%Ge}p>!OZ-=^%`qyYN}*ueKGx1m)yO19)#e8y|;IAerwg1`zYKwe>`Qe zR9`8u>yULpu4a73e6>Tl2a(;5UvHe-GYOuj0_wKg1NH^>=oOu}uy8@2W<-lqO zDdVuUe3-!PhLl!i&jDX>1rvrqM&emuAZ_G$XFlgrJ~F?H!TXdgwzRVU9g{>jgG+ti zW=@(c@rZJ4_EL)8dp&bl~?ZOeUq-xFS-aj zgs>UmmK7hJO>t5Vs9+wDV0Wm{CSl%L zTK5cY1#|JoOpDhIX<93;|GC|yWPMDl|T4d<5=QN5Mz2i`}qybJ!D*JB0d zT7D>fKCPaoR-}<(2CY5w>MuH#%+oD`j(;@Z_c_UBFY{ifu1$e;u;;$x>;P=R_ zZ}Fg{=}uaja}Qb|!Dy=|91eae0Y1G!_LD*-HQ>Nw!-Jwv`st}r#mc}p0`S2`!q;Te z56IU36EFVxgO3|dZ}QE0mBodV_tqzwT8W80G8m`V#r7`t7fGDT!MzWK7AbzJnVVBC zE-kh12~0T>w6wM)42%}(J%>F{ny1zxy+&pWUAJ8wm<}*(vYJ#aK&F2{)%AwcDX@lt zRRkTfqw4LwMKOt}06K_*sH}&%}ai^lwn} z@tb@{tuYzVN9=geI}~s8YD*ny^tu*MhzOvLx?IR3^)%)oye2tfg==z6?*; z6})(fnnC&qEfMa02q!9$=rSWviyjNK`zcnb7BC$hp=#T)7LG)sKczr*j3T_gL^bkS?h z-O5BFMh4C`R=dktdKlmQvg|;)y?!ywD_0}I)%1o)o-(b-r!Z5~c!b)D>J&1d)-E#f zqrUft@8J~jMpNDk!zEJ@tj-QSgVwigtJ*m!sIt*)8z)HCWY#cPkjuRGoi#I+@9vsY zb6W)}3&1^yyx46Hjo)uE~uq$qN$as>Vvx<5k&kD;nbvq(a6PD8BwZ|=v=eoIiBAUzBc>fo{ zvcv`KAnb{(Mb{E6LGgH>^VT0PT8TmUd7%qMA&9T~q9)mbd!mvvV}rB4So@%4|J-nK zIkB2Dt`Zv}d!gHTghk>4kH?5j^r2Xc{Q(Wn$BR$70*i91b`>l!K0c~fkMxLzm87dr|ZnkEe^8`LxTD0Yk;rD|5yCP!;RSm>auU9Y>SUt$ z>m%6WD@Mz}_|k`RVBPKZSN5&zJr^C1j!??4ZXGX=6?3ZTV8W7myuvDuj|Y{jT3yJl ziQ7R=Zk3bu4%Q_n>Qq`OU!&;eqn7h-y?+`*SW43Nq9&q+or}vrknU7!`u$Tg?4?j+ z?UygBL>S|O>#ONYKHO>J_R$9^eJF_SZ$DT^Y}$=;S@^%O$g}SbA@9!{cZguR#o>*8 zPx2kw%TR~9WCMrx6d)FXERnHO*%Y;jbsnK;>@osjcfA|p4hvv_oH)$wj;EW@2)V`w zFmp!55sAnm*;ND!5=5oMN|zwrN4C=?(o*i^gaw7h?OH-hcfDU~Q*eR7hl>ks^i)qV z`cVuOxgdAa3gJFGL zcHXzs9}{%M@ZA!!e2AQz2!8+uAvBnb9s*ml6V?PpgP){P&)1a_+cN-P`O9vWmM{IKSV9PQuL~x@ zl1!%jlJ@-#Qd{~Xk9!j#BJ2w1LHg3=JCdN$Z|9 zy-oN;qS88)fWO^L{G3s@3_WsT5pbrv`Y-j8GWdSslVGZ2u>u3(f9%gd)hdu0mt57R zOEbc0v}bP0d-riF^sJj)*QVsH2geR6{(r2&(zwH|+pz}kogoi4Yb-rfRd4I>$wEM4 zw4i2ruA8LY*tb|!8<=dWJ*7sN6U(Em$NaLhKa95ux%Z-Lu6C)npIDBjWdtZ$3cf8l zj;4Jy{P`&(?&|*61+LK)w-f0f+iqDVrxoV?@%i1m9kQG9S9ZwkZI11r$Ik?zdgg+v zH9E*6mf>f>l0`DIlch+DK3y$UUY?1}=&1lT@n{w$VuTIWC)~<*G0PAEJ;=Acl|{3Z zbeZP~{aQfcRe2{3>bHfu~W2$hDOyocU{?R#=bwYAqm@@vRz+|cJ_pOmxLh> zepqexErrsU_pbSvXN@JS_zzml9Saq1Ph;%X9qsmyi+4(STAm-x-$_Peh9xL9T} zcuD8Zk)oI}NvQwY+=)rR!_{?Ut!0a&u>I>4bL;gKE=DaM2`onqV0k`&EatgKOt=p3 z^q?vg$TwdM%DP0V7IudVyLHFV-$04BXGAQFKe^9fT|z)BV@5no?NrzWsd#uCOecxx zNzMvo@~rw#E$gbGwHYUc4io+&U3+1qx3PRVAS@onN_*B1=WPoMe|_V2I+_n42J zoZzSzd4ne&>y>I1}36kpyJqR2JIkJFn-td}x-6Fgo; z{>Ww3Q0j1fEKQHtE0yZkGAWL0-88lxYeP-z!vQ~%Wcow*gKy9sl~7tAp^sm-^Qzm1 z$>zE;UX{y2#%yN&v8rM)<(9Y?d_h53IZ4@1*19gtwdZGpw%;A6Ha=x%dy4&l->66kuXF&@v!}H+@8ax*1T=hCZ(60pVHUF?=1tc(KNnRYZ%vJJ_;7;qA^kG zRjF!=gdvQZJi~CUpT7u+qANy+-%{1nFw_%D&!IM`Hw~yRdRg&uNN8P=<*^>$OyOQ$ ztEWrlV3*s5T%?lW(ZN?S`<0?fgG$rrS_pr=PPrAb$9PE9)%8AS3+s@5SLwI?-gWl$ zT3x%W(SFXbLZnji@w5vFp}jeux3bCGtHoHdZR0l0k$58n+#a?|d#EApv&hy>dj|l} zuYu;xtj&TSUxAZ6j;|3=^!iSgTMudqo?JCEyo2zO@K&7mLi5vQYWo8zNZJ{wWt@)> z9CYek`HC|zZ{~ROkP;{ue7T?$W)jIQ9hlQ>7R*+D+@!E*o0X2QA+huzNd{BrEi`8E`+w|68#`-r1=4`mZm z`}5_+sy40lT`rliQm^d3h^Ud?atTM}p9(W+X&?Qp)Jao}`U@kl8LL?@N4e|}{)^uX zDo31;7ncI%I{+M{VHqvV2)?;Kc1b9_-tq8-;-plgeovPXwB$)bQEvkRb#cRE70oj4vuE~U-fA>ubdZP2a|}iz+xj3V z>HC;sN1GV#Ydy!poKS z6j@bo++5A403q%hUoo&WvV&Qr(fTFuTPa!51bUR+uCL=>cyU z;C{sav*1g>ZIVrmbGpj&hUx0nwygPB`gJYsd6mM6w3#n72b8bAOXMw`%wB#cBvdVZ z@+h%fX}<~$$VJG>tt_J ziQuGbgbuRTl_(-ThpRQTz@RY3yj!&qlw5%rc{9VssSY$yDCE)NGPWA*tq-*yTRg0Z zikfzzmQW84@%SlMBjl}$p*gB);{&!wvLPShLWCGb6qVN`b;b_z#Oj?5ZYLI^_kxsP zy{fa+T+7!;uJx+ud)}cXx;Q*7HM4@%5P<}$L28q#O1D)H7ZoY`6=6zQ8L~Vs4ohOP zYhwAQ7d-bC1=v-kvNf=~F8nkUbFFYV{8jc-M#I$uRi@0SLNzVdCyVW+%A%-SF3A4D zwKmidXo6sO!42-drWVtL5v|s=sac(%F+a{w4ydD4OQ;tokL5VoLqDHuZ`Og#cZ_y- zGYvGp>@aLIg6K!1_&7MaxS^KWmHlWn^lIL&nup7xqgbw^3py6%TKUGzJlo;(@pT7% zjjK1V@gXu+qhDc$3uQYs{Sqv*_sza~21OI60bCQZ{i8BZQGO0Y*X{DpTmU(J^AZFn zU*S5V-oML7_P zLp_xClmLZze^7ZnvxI32D2e;UI$Jr#mRJ-h!@Rv{+22gq*!1y6>ChcDVcgCb_DM}) zi{%@CJf$_l--^^34G&I#Ew*oUl#XHK(i%e19M6AH*>d;^kSy$8Tf2XDP`PVQ{^e1# z?2z>2Y2o;a#V`zg+Axxz29tFeyLk9w-5Swt;gdA8X}!{JMfkFr#t%-=RLn(oa#_N)q`WXHvqHxpVsPDp$;$ta6PY~$kLwJ1kO>#}4& z#5juTE>l}9Q`3=Hg%x$h(t>wNulJO0eLuClUdqDMUmZsVYQl!*3t-VpD{ePUj2UJvBmn|%!qM#y5xMTs93`)*HR5FN2&XRM^p+rTJfJn}gGf2)9 zNRCC$P~;+~q6&%%yuc;M5e$^ybXIi z(}*Xd$|E`xXaR;c#RcyjXuUOWOQDcD=Jm~+vGF$IdJ$BD*%7wN4T$YwY4B*it6;rj=3X z++byy3%&M^jwF{;d2svWScoM<@)54aMX5|?txms(Z5F17bFR`bdLWOTl(tIJvNWGjPK?Tv4iVcPB{TtYYB^!z1AcHZ>( z@CI$-CAYcVtW=VofLiy|y;xIps$rr?`zn!t78QSfZ?`gQk+ZA9-e@r9IvX+rLMm#} zM?XC=nR0LilPYqz)mhAHK=8)ZNw5ImU>5PfEB5G@OQ1{tRljg@r&htt*wwH0vwIEj z9DDRJ+A$QoK>YvX5cBjp8G7?W!;g)>6fwFLBsw8RJhF5EJkN>lTS88_1@YbdSBr1s zxWTR1;ZcG|+^!TM6qFIYVtd-fvI-Nl_QJZ>UfM$Dw7iT#78Vxo{9fK;4X1sM->JBb zS`CuR$I?@hnNzC6I@AJVc`aS`=d#k^7`X0R+Oqq7Rb@ zM;7}f3JvL2yvX{Br5{W{(!y^!8ic&4gnJ=9Jh1zS3(S6V*biuhzo;_$h}Ko2biOu! z{*cn-ASov&`NJV9Ts&2ka(jDIq)L4~1AOQHO9m)ha3?oCt5hOm@EgV&}@XhFx(v8>toejh2QQ<<3Hv@1&E}fRs|cq&$4|R;4BVy8cqDZV*VWAfEkl zK6}1yB$*=bZTIHAT&i!hLF&Xq*%MFUIjIHDh6)i!FG$tE&V}pb`Bl84sL(l?yXEp% zRbb2GYefc4m#H ze!cif_pStxulfjoOXJ&Cy-sV;AXieT9a|JJRLS`yuRNubRvmJdmd!y zy^BvsdjGe=(hDHt`R1~)!oC97N(wF3hmOhB*q1OvuHg~c8iRt4#Ni*Dk4jP<*n+l3BgZS$8) z@Y9o&!*O_dGiO?~d+R0IL}Y&^{R8qIg>BAs3fbzLjKfTsLO145pIXMsOEzuUSRFia?6Q@q1xMbsn+PKg`)C+1@Z($y0fRONnbtA>P`Usbz zxPQ8v!zuc(%8u@HkBCkm#ag#=vRn;SD5UcWGEF9H40O><)H|DoJv6Em@IEmsGRnAo z^NNlAdVd}(wU(w!aaiiDj;6PteVZ;Y)&d)Mcg`n`ZP3F~Hcp-tB$=&LSg5#$dFD^% zKif~Sj{5regQAm`VWfZNsZ(Q_{(*;` z#UfB$)E9ZrCgFVSe7c-?=NAvpBYPX3+$+!Mi7)vG2E5F zkcDw$B6rSxr}u@1sL}K{CfS1ckmf*cDAxV28#*G&J%59lSMAJ*anwe z?nW7XQU5akV*OM2*SFBB;fk%7l+ZmDDs+3hr?u2!8OA z{p+G1!Rt4#-}R^%uu4c6_5+nG=&tgX%gO{qU)Y6r;2sfi_O3&7++6jpaqXIWI;zL9 zYpiFF!vP4Sb7eq_KKGOy5J8^_MNw6sD-}|LcuR*nVz5tuVqHU_Yy_1&pTnxDCi0W? z1w;FiJ8rx@9+g0>&189xQKzfT6WmxXAai*% zk;q}8V|^#p9|?3^ue}lRI(i&ID_T3YqoYBlE}%SHUnCM0HX_O*5xa_>%-7@&1#dH* z55T|wDa^Y_B(SYW0xuW*jXrNFh~t6dxvMtc`aQA#@JFwFe}4OFp<|{tI}b30s%m}i z-GxZ{b8Ck7IM*5l2dzC8W$9*~_`VNdHCg3_xDV!=5+coV#(~pFfhnvkZ!iFsDrdLA0K5C3FL{ zb@!ZE6kR{^VuGV?AtY4y+$OH_jc}pQICRQzsHfHNuoYG0bZYy?`S?yHfl-F{_B|0F zK6R1dpl~^e&+3V{t3^Zj`nj&=0+mlwzy3uZk0&n?{Xsj4u($F=V-NF_5vC~RIB@|7 zZP{HqBl}aNfKkQM`o-wej?I9O(1`4OL$8P#hD6#5)3q<~g4*(0$N6WebQ@(^raC%w zyMA3Y9a|^GR+otJ@$sNF zn@?_!HL%_pRjqsJ{McPcPE6BSsX?&H=@|84AN&zxYNBvuY5FE4&HhAvUTV_S5O%rJ znGU9KndCdVwh$R2&4=MxXC6^D6rfq>ClZ=9H)LX5!Ulw8a<79JZlf+J+p zcVlWh0@Af1f`;DGgX}$~Vmdz~%1@9e7vT(eaFDuKIWM!vt*J**aGYp)3OW#W2HcuY zCB4CIG;7S=B`yh|)}ySGo}mk7TD8NRV%@0t5}EX=w`-dYv!gp41$plKc?BO|nz27_S9ha`6{n+8XnL(d(gsKq{d4~ zo}m{W<8nV&ty^=Yx6W8#|6)TlATfE-yM)TXSM3$H+d{`1dUSLUwOB$lujIK3sk^g! z_zGv2oT@)=pcZjBv+ddYoq^A^`>rlHKwKO-V>;-0Og6p8`wlJTuQBkm+T3X|cy@p|OsBu$Q!ds=QFd#Ru3U%j?PDBSmPUeFy;wfnmn`o)pk zN_{f9gu#X$c^rcQLn-j`LIAVmGzthC)fxFF%?N9&?sfJ=`u5}f*qss9R7Ij%PWRrA zQ>xbRu74>gabpAk$l?{RIh8;flAcJ83uG|_aqv!-IUg!qg(HuwxYXT4mw+#`WULG0 zajBzeq^Q?Uwv{`UrWAh$?IPu=LcssODkG zfU}w<4H2}9QPcS0*lQxIsX;IYRx$;N2Vh;RPhtEj3Psk=q~`hEj^K;m{9I~NqZ}7q zM`aoVlNmZC}w!Fh!|S$p#Cp zmZT;fr}R`j`<&qG7gtw%QbMusR|1{mFWv@Jxjh(km$oGLJI+F~pYHYv!P8YvGgQ%1 zetm5p&iwqoaUlTEg9U{cKDu|47m+Z~f zG(ZNY-VKQReoZne?MIzUeyzEV=3@!RQg4&6r(=$Ce)o37P&aLy=Wi=)Eo9HAfJ^8_ z30;li+bgy66lhQhJsiEft!=O3v?eA)-Ai@56UHW#6pC2*Rd$3L$GO>g%=<|%(1&2v zB9*^@^65{^ZI=1?y-N2D!FL<+&AxL~y3DEGh>V&ujLJayd7!4@rDVT$D>PL~%Iv`; z<<#)t+O0qA4f$#Fu;5K3m@O~0L!&iy=PefyE7cs|+llb>q80i`jO<}`lgQC|4vLHN z7t(uGKRGV8@>nMpeugf;9B(3#J$jF1*Lr143vUyz_CP3XC)!%G@a%mN!WKiU#=GxX zXrw!dX<#SM=j-xEm`o4yv4%9agkSE5LnP#TqMB&+gfH@U4xQs}rhAxw4RIQbexcE+ zs?IunPxMD+D}4w{h6>XAgvrzz7D?(Wm;|l!Bo@CsUAym|O!qabOhC7RWAr4;K&5Ok zbVk>4`f#Qq`*e#bdwUu0OfkX3++%-1I!gI3MSx-Ep+>UUuyrG4-IR|5#P9uw9<6+j zaS6ZRO$<7!C!m+y*0MH1a2V^IN;Z%%mKfd9rGLL*MXb!%<1mZhuqg%*!q|#u*CQJt zh6c!hhtEelytVJFAYfM0<>4%q9`}P^=hP6q%9)6JN#Mx3yQSvy;l%4=tuBKTn@`kp z0?XiGydjD(x1bUBImPb?it&gqJaZ-iEC&+aUhf4~{p;-U{sYS{y;ZqBFo6A9&`N~17z|tjv^!THPNQe9@%7c zcD+mNG8lJ0B$Dy6+Y*IDNdUNmcJtte_6?QlM=9m4kEh@irZg!Snu-!0AZp2L^bE}{k8{+4urYOPM#$U8d%W)tK?!F&|m z)V)AFL$66U=>zKR>Mnms@?1qJv@u>iA0KY1)@^YH72KwbXcwgDB^Tp+tR zN~WbR4QM}$E2j!Y=K*PaRMU)nnSqf*%#R;0f!oan6mjFe-lKk_Dvlt#zS8TrR0V$(dAC~EA#O-pZ`&GltA5iR;sGpjbOa|`O!t^TI8)}>s7mc{WS?tg}80P zfc68zKdcvD+x3-DxL4 zcfc2bhUB%Ro;T_c2U;Z}clhF()zF3Vn0E==epErP)y~u|Uy%-P(n+UcuAZP{7TG}j zTLJJj-MeV*dsOm%N{oHeT%(y@2cnP4X4U%W6)HY zj=VSXtM?|E<=kj})l=KqqI*xDG7qq%6l-zi+v^GFZEhEXOimQ_jEYrgZRX3ECgqf3 z(KcsgX;=8mxhBuLzKUi991hP1klcM%b-}N0Oc>19C4{H~K*<`L@%Ur`TLxIA$)aw9 z%IvIgkc1KbbxfgcUHbbvX?#MWd-EIfc@j!7&1*gW6;W-o!4u`&JjAV1VjIl$$={yBcO~bSL#Z)0 z@>V&3{?r4N!B8F~=*ma|DCr9**_@w0g4+(1%7$>?4Yz!aRw2#!{y6P{fWrew7}=_h z+8}(xjC=ZyZ-Z)8f{no>*)G#_TeN|*n${5>KyGpdmr4td<&W#piIEibQp7^2*Gm=R z59e6ZJsCL-o}`bG#djvK9>MUU%s64(`ELv1XlUWQOM?UMTv-9NW2)|v2ER4H(qbV^ zxl{te>dYbFb$Ok)5)74<%UZhz6MBYz>~afqUG(AGn?>jaT>Hy#UIkD4#oyK1W5M0OJ}DXA}CzO>_i1sIP^&Q&;1KEMStc296Ys>))hvf9*gz1DtS zw_oAM3O7BKphK$LAYv+mR(jSsAuTWMM;W(k%YNqp!by;Fr>Ux#h;4WS+Nwl7lDZLs0T;$TX;t#+)~bD-NutN$~lC|008 zzY1gQW40|ZcX94VQfP?r)gpPn>7EZ-XDFYN2cPqNoseE!*m zI21%Q@lML?@oP_&ypZ@~(j~q~j0`@juj(^eXuy~Ika^{v+C1CRG)Ea5*>`TiPr|Iy zq)ZUm+pjFjwmcIf9fV#5TZD37xPVBVNFR*y1TxM~{glg;adNq_u{ZEzjZUXFibo5D zyMA3Zkmb+lS^{kp+TfhO*hJL=W+xDvO$qEf?Xs=jU$6`+4PN*I-FPq=@8PXCZ(BMI zYxV}BzqwtkO=m>7Pl+$nOd0Is=ObVtjiR2nnbkH}GmQqUE=kv8zVS5zIsS|_q3s?Y zpwsbLKy6s74K{T@wGP+1S)LK|0|^Uh1@f1f8DrC36*(v2Ss-cY^pI}$)J(;OfSy-o z3s1wz!!mBt!C93qo16WTxf%C{GX#5zg2QD@u(QI6X(F?CMtGtaPbC2=6dcwv&lH?> zw07LQoe&@YENj`tO?Murjv~}HGP)>4Rk$KA5QD>c`O4tbRQ+ULG(E(lC}SVUk1d^_ z7eTogyIsBj1Um1r6O-}*$=cK4fXkXrthavp;R53z(4ARW7XjR7KU zE*%BVAoH1Viq_-k^8U)aj)R}|wP_p(pC`p<$kcKQk;MWi@yUNlCV8C*dcr7nT z!Jx~4#)HP)941a#6J(rFBbO{QGTve^QJO;E36ImTd1lmlP7j+u>I+f8Hl5M0V9|CXbe{BlWOR8&y5$H(PJVN4LA*ccKe3 zBYyG>PBBb8Oze0|%;-qWeINhMbu`}9$S6NXUCSUoLjBwckXWseDdM`pQ>&XD|Aybj zB8f^AYhh=|4H9Uv6uPEIHN(?7H*Y@QQyW?|!XbOUPD)9slewljVazsA4JH_P+%=*I#0?uDd7q=5&eH;OoSkDhIo5;4}DENAe z>KLFNz2mCAaXO%u(~`W))_gm5$!dkkz+KKG3`8>^_u6$FNuT% zL!VF~n@aJDVc?LrI~1KXEANZLpgN;N+=U0fq$L&0ysPCLGv6)hPu)`izjHhux)*G5 zoX`=c`k_ZxoNT?GiDm}tL9yK6r)>4wGy1AuJ@5w}(;!2lnE+6?Csh=mc^Ik8W*#o`Lw!0=)d|fo(gy@+8p^ zvS^jphA-2NMx15YZV&_PBPL`Je(|s zey3;YQn*SphJ-o1dewvbjr$EF@uTa2RaBmfX>#y>>wf(9>FqB;L5kuXzh4%AY3))w zF%!jBmI*F6eSWVH<Cs$NpU5Gq>e7ylwqbLa%)g zYJ-9(=3udDJU78m0haDtMj4uvmP>G-rl3({wz$%M1u!*a&sK-F1vi4aVv%Q&E-5eH zGZ(L6kHwQ*O4<@1C^YukOA@NAtA7nC@%0+!>R)nwFS0iEa5Mnh@y(T-lwQI8YfX0A zVaaoMWvYd5Gu~Rcb_^+LiM;Xq`QPWFNjYi0n!cVej$1WMITU7zTSJFwvYi}3(oz*A!q04TOe5b z{n$*}0}a{VmWgv>|GCQ?dLmU`y~cU)OGISIHC|oE9^gFhgz28oD`ru!w0^NlED7 zykmW7%s6-5+KW27*$*BjmTHUJS{lGR+*eTJlcS}PUq-W^koHw$HKd_&yqpaNDWs9s zOz2wkQ5;GU%m+@TeUQ4Px!4@cejQYxsX~TF_GB_GK*M~0t|m0?i^hk19kNNSxNR+u z19M9Y-w(6uc}g$BleD~Z?>u^@ofys2{SnQ&tOOG+O z0(UN^<^ejSSiGB^`$kGUvN|g!fO@u`HJ8g-Rz$qsSm3GKfyoZpblD?!-%UPcUE9X=PE^t?VeXNx*4Y zAFp;zl|@Q|#QYqp65X(`?Drt+BbvEIi4Ei`1CN8UY~*rz{IHo{@>k zdTS)JQ)M7^25*+`SQ}wZP7_+s@ksQrGNxEzaD4hNnu@bl^g%XYB2+w9tXifGeM@&7 z8VtQwg4PcYG1vr7-RkG_E;m*HfqN2BjRf}-GXFY&C1lg7aa@oh0LYVA(8?yLC@Jy5 zk<8I+HwOY)YGqrGvsa()FL4bL#fE`?-*qHaq}dik{UEpKG_>ecsbNc# zgXArA^surB#3KEx0%x43(+N*btjE8A3Wuoi&B#ReF9-=)R_IYNU z1#3NN*?NcaOE`l$Cj;hC1ER^ch3ZKLhh;fG&!6`%#YrdazSDS72Gr62;Ez-P!@mid zl%&@Tj63Y3O!pd2R`aBqIUG05f%s4S+!(a%$Ks2Je!QSyOg{v>nNI8_v5fi%lc5x& zr}OtB4Z0C5NAFea{{n3IyIUtV{Hu7SXOZ6MZN>)Saecbe_UN+7CGWnb|Ut^o#?FcrJ-Ktd4t8=Ic z*&E6H{PZx^L{=*xpN6 z9rxGSoF}Y3l#p?!IN!4a#3ZS^x!lj@w18dcXy|!93#vlziIVqiphwH85$kn8~arVPox4Dh^^+{;Y{UUBS>YUOkp>c1|3U53?P z9-+Ed4rx*|)Xy;?g-mfDuStU0j1=~qMPBn;t5BvGM27vQ_CWB)6bxb41_n_HX#qNU zM|pyHM~R{07m27C{|=Qfnmg(Ss+^06PzS zPW?EG1DLwux(46?|8n=Ir6b-Iyk+IRwKjJblj0jTU@*})$WpcJuRE*G8&*RbXJx6~zxa1-)X*Av9{nVG2JA5o3JK@DyK0|(MfAYY(qe+| z47HW4L3)s%JL-nhSg3uD!C|=zX?fO}i@EXp=Rs3Z@uS|tR8GTz9Js2$6CT$uYvm-z zy2967(K1yY@5Yub{MgmkUbL_X?hDcCG$%xI%&R69-yaKQnk|}J^YJ?18b40RzEc-~ zpb=?hk`W?4%={Kdu(g%^p11LrtR?9mu0N~7)%u5pNQ^nEVO;9_LuBu7baMNLUuM!_ zeu2J_xup@H$zb?@j}8Zl_jE|SzlZcJKAP-b*X_W+<8o#RDxSzq27kp zY0(cEihDGmA}XiYeJ@`GpDT}Q(O3O_wgo8-g;9w-j7A|Fh%UC5Kh3_$F&f&QmSmBO z+@^TzjT4SYGwt)QrgOv1iLo46>TRz~q8e38RirDSZ51eF-*^qWaE$!APkwrm$ry8L3UXrTD*Wu)w zE#=gr^pRhwNk~?T72}H7OV=E}|I*PAFTC9?{Cg_So$xksRx`&MO6}e3*tTbV$TFwo zd1KD|pW}|LAE!;iCM2PociyK9k}2LZdgbEs+TjQHBe>SY z{7FGVI)qN2lo?1ZRtW!+wDZ7+bqG=Xz0HA*-cL7I{;z9dkEWj|SJ!b6iP#Hzy2n3% zkc1RK6R8ZG@$GR(_v$Quuh>jOp#AioCbJ92+F};w3O$kL@;y+%Q>vT0V*Ix;s6)Tq7X=KijPpt5X%-aZk2UW6fc%I&@fQ z?QXE z_7a_{f&!J%1+d7G@q9O{;!I_q9TwhXs=Bgu)f28LhT& zaGbkIE;&XX7aFE7*q~v|mkJ!ZM=>S&PTD<=d zP)82ygh4@<{PU==PFn$&`a<>mNS#KP4F&kp{18Ay-=Y!Cwc8em-x=8!$G_d|W#kBL zO*_5auj0dP)Vjdouu$(Jypa+XxB85aFBae~3-NYZ<=-36S7m*o*-~aVFJ#{MS8Uha zneDn^gne#-MT}~G#9G)PMlV_goSWvFX0l(R0$wHJsXVh(o6758QdrE&dr7{HQK{KM!YJ%2M;w6qmL0e~Cw!ZzH2*gF(7 zb1azW%Nb@xq8e>)^ZmeW@P4N6OZgmEJxi`6$66|1nJ!Q6oh+}+ z5zGM8>Hw8`l-+$V`Zl`%9;@CTVGI()slPJ_)@81Nizx9VB|$dNPuX^mgL;-RcZ}S2EZ?#!rK}DFTxH885Dp4YQ zI+(=!_aRDj(dkrY<7XctP1MuFM$<|sfy-b}0KPfId)Ttb0VLq`$Pjz8<0UELzO~+m z`xZb%TK%Cb>?Lfc6lC3>!e`BxrXTkW1&0;ul}|Ug!jj!ve9r_9i^n7AtbKswOOVPw zMDj8xHa7Ow~xN=X*1^xju1f2`Q zSlojQgZa6SaZ1C&-(M31%qh*M8^{$qq)Hb%TqJ1=#(lE=qV_)cNn97%pOxrU{tE)FY)~53bByql$mpoPBpbu3C)FN{oGkK` zpgCntP4cUfriDDvcUGuVWns}OBuHW47CoCv3o`QE=jU|iEr?CnTLsZokQxj%8I3nI6@v#FQa z>FL_oByA0j+Kl6tOrkYtV9=t?Q|bqlN&$ffmqb?R0mXvlFYtuFoF0m{edpQT4wZ5q(G za3A~`eeci4hqp_(9>pB{M?9!wEXY2(w&~^3OFckD_peZ#jFD(xxuB{ae*F=FUBZ?P@ z1cq*YX3aY@{NO|U;PD*>eYuac^d8;pDSX92gi(egn z-DJbB=ydmykdY-_Y*J=5cKR5qnZ%(1fXnLM+w<#1}wQ3IQ%pyQI5 zby}X#N{;{ti)j5?#FpZt)vQqavbhy?blAx*`75r94YQXsokxB#*|!_+^l3jh&h}G` z{Go4JrN&rwhSgMMgT3Urm85m?CRbG#Jw!o?09umRIx%NY1~N>ki@3~47de<3zrZ?| zpWD0u1(i>0mJmABIwZ~R1hJP)e%)WFemTJR&&1SzCf+OpAGGwb*Xv4D6ps|^8qW>g zR;r?*zvyXXeu~sT@_Ql)D7p2~okp1vU&vMxV3}q=}Nn_jgW~&wvg9a3YgJwS=#u~0;#8}@ucS3B)iu(-aXZgr zC5WPNry)PhBbg{y0(&|h>Q35yPqR#!qjz-(`00e|+M@b{$^kBqE;6me>%GDB0rmFH z$K9dNt;YH@?-9~u6xz@Vh5i=uTxu~5GW2-}f8_GLzRKJVuw^2s1ovkej z2>TLT3T)RvGmw>I`;E*)@JEs*y0&D{&f)=E!^6wPNnJ!(uq#%?M?(WTjMg9+(;ECn zP>|p!(DR;Tre=mai(tX!BdZdPLlMiK3|LoF*j(O=EbpI5E(T^jw8DUwZJrxWVkV8Q z$u^f2TXnklZPAO-^LOKQ2X8r#M*%{oj}Q$Cmmz0F>BLt9hu!b#IAZ@>3rZ$ElDMKw zo}DEy&Kzz(mVGvt<=Fa5Bb;1dtEOgep-aTEDH|aP_p_bk7onN_$^88TjErnUQ?vFl zdE}mj>*iohy11ZHg#~W#z9w!6Hd5aC-WmZ^UtFd2jGW!NzfpQX#Lr%RDtdRg-2U0r zsB*J4_pwWwl10()!;wrG19vSjF7bt_lH`iV=xd5uph}%YdU3Zb`{S>o&7yusZ|?@Z z(~Sh9aR}hI1%e7ve8v-Nfh8C0wk{d49>@Y#@E+Eu{Dd2(2lE&3h{?ron!T?| zZ@Xa%)9I?`u@(FoUPC{#n4I9StQF;#hMf?aUv0!LR9aB;)!842PLM~Xn#V!8Yd81x zbc1Pqw0D(Fjeb1%3%%JY#L!x<9e8omQK^=$2kALp>JvBTp-N*;1Wj@%hH=+vQpYup zI1f13KkUJoAawOGD}$YUj{i*fD)0j-OGy~G0$k|o?$2bn_#*@r;2+<^4)%#UVwX7I zI;{PT{jEAKxkY~pEVCrt7o-1FzWTlPDdRuY8IRVa&ucQfgFj$$RT+$`UV^i^M8-D* z&OJT{2W%7=S48HT?#!Sj@>J4SRV?RDDN$~^){xgL!$9FAkr*%Ez+0Sq!8$)}4-S#6 zbA^ZtZX~fh_FIb(Y%FR3!Z4MFbaH^T^2NRj_^2Q|vJxn-4c-@Ce;~=etr2H@iGqL{ zi^_9zb3@ICk|#0lhX5F2`(;Odx{u9=mZKETx`na#4ccTuwzj~rr_4T1gj*|jMf9M) zBi(1GuzE$~(DkH`dD^E$Kh3cbTWzgQt9v)&t^JwUVr_uY4(KW!wr!Yav%}6L8(D1{ zB`z?@rfkznKc1(OH1-^pCcOd8qq`tm7?cH9@+)dRyPHyD<3Q_qXilB+Vnk%WL5=aW z$)c}6XQwUn-Ur9`$DX)fho7q!YFh#tBM?G);F_fpnY|3h3M0C5TV8rpf4r*hIt2{YtZ}y9 zc`doR!$R-ykthi39Lg1}MYBa9M(_>)-;c?)wrXd3fl#RLT@g%THB8v`jhGi4Wy#MD z%3J(3OjOJfA3<1C;9Wt&{A+bMs)c6mSzQct!6$-fb*nU>0zh312&9rrwJUxD zGzn%v_*@I1)E0q6bQ~JLY(6`{5e*b5<`x=UOU(!3Ylj5p>)+ZvKT6qdcRwgXiUJrJ zb-8*=NcsWQdj`$Sc|RK}_15!MA|N#XF_a+*jd+n_Uz=p#v|l?_ZUpudnS3Kou?Ple zyhR^H5c^-8I@1-A8p!P@!HlYbm~tKtkuY7mod0-P7oKQx2;Kich`KeOoLgM-8m;|w zVdmtOA_=()2V^B>pH6g4*X>&r`}hHRq5rFDA$%#N&fY|C$<4T#&9T#v93(c7w^L(YYzD7N zltyfXhGEW?xUI6bao>+zWG@(J53qsPfEV39``V8T$dlldMJ)Ds9d-<|?N@xqV=M%7 zLpO6dOR>ns(na!${`DaRJCtX*{cJst9zV!iGoeCyT0pbWUCXu@+o@0bKvTn5<6c=! zR{-m{&-i@@^i)c9UCn@&-s?RYH#*7ABJ9{dGmCgPR+wn z$9HLtLJ%Am1Q2}4XW#o*^Ys@o>g@WZVOSLYf6M&77NYCsO41|FIz-Z>QBl=J@CE+xYOFQYtF7lSv1mn8n|O1JCzE0rz>~QVh~&( zpd^(a=H3C9wRXTn{}XjU(rBsn8z99~+4%!UHSaD46W4R7~i%_-^aMZUQiV& zODHz^X}oXNMnzjr=%bPnyFk$nd8of5R`4*P$8w;MEUPWaGTnM0p0jv77cd_IS8?N% zwVnW*7+!=45e27<>D67^uN-k_Lf>q_jQ`2RUFj`OUcdPWqd{5h?hJJ=541ibC<@%3 zL1^8aw)Ea3_BzhI?5w)x94rM|MlGX^{g&Zf{tjjAfAzsE-9g5FX22*3pgTX~zeIn6 z1n)*=c+uXdnSXxA@V4!GhQCNZ za@JEaMZ)x}ayrlC1#;VTf@sD(o&&YS`j4X)XHq+#67JuqAK{Mp*Bb zvSQF@2vRXaM~b1qe-^9a-8hl|`SBCDf-sk>?k}`zXK}F=^$X~!EUMxX;)2~9&*{wc__DBA3R8T2r;&f2|Em;2{K92VJ4MLEkK?rI_Y&Ip zl_^cC>D7)#JpB9iOcT1F|G|`Tjby#O7SEYvx!-gewh>?o+A09Muagm6;6d=#uKn8~ zWk;i3Yvz!NxiD=pkd@!l2?LGlTI3&f^pj1MXzUJZT9iJ)n>8PIbNszuy5gt{9zxI8 zUq;qN2=18A-&+u9CL9N>l$7CZKLkS1p#%{5E4ALs`sdp5A9Vn;ucaBUhx$FMIoDu` ziAvUha2Sq#vksy?y0X2UJ$~r~-T2)=3~)8>9C`ouwDM&TkcC^M1XxPh>Id>GCTI^F z+@rvVd-b9te5E72_+}^TI62s>1ixZ?BE>HL^4;C{EcLB(n5u$(MJ#g8U&+S{lO^(6 zmQiO{t4HWiHCg>ri+lUA@l?t82js)3VNDLbJwjYjNgOW2*F8@4`uIT>{y0aJs5iIY z|Hv!+f21A$_4Rtihgrt)Lqe+v?q4o=8u7bD>FiCAo^Q1;vL#*fkGqd9tNQ!<8`B@o zU(K;F#YkdKg}kz9TsOx*e@||A-Xvr@FIG$z+^1$Y;$udZDeyP1mLDh^&q2=0uWtSC zbJvXw<_lRDz-j#3G|B(_I1lNHPK%)=BQ8&u-)&GH+I0Co z&sVAl3MZenEe9w0Jn$m~NH`jenT#0XE@-0& z3a`jgof<{~SrCe*_=p-UDyIb#T#ef}ACdHu_=`TP2d}%wiUMkM)E*2WmXO4F{ci`7 zY0^!ARlVddW-t9Px-fHiqx;>q7Wi?W^LTx{wH^|iqoift@?A>6BWWRqfm_1y>Wtuj zao5*;3-nUf^dWFdR(^O9uc>1A1gyXP!Rs$oDHXon_d7{fN5|nJ^WSZo^>xyQCz(Yx0P4@rw@e_AFDOg!6Zm7;2s#?+ES`zc!+-5cyQ3-5<8Trhzv9t3^-nC`CGG?SBiP;rB!2$fwtnL0}a+L8tf%%ma z0V(GHXJ0;Gc>6^Xvq`OWe z+pHc{8tyv&RY2W+h#7|{P)_6NBInXIQx!)y`vb7!KkZL4pi+SxB;O{2OW zvtb1Y#R;VU$N%$x&-efQ!s_?+8=#bh@3S@=}96fkWFf^!y^lqeZSvjWotJP4IK zU#-nCqYTt`>))5`cltRU^8~tV~gO;NOYN>+sVD=jx$spbCxk?CZ_)zki12bu(E^EJU z`pI46GD%*NypbIO=~Vse2dWE{Er965(VyYul}D-zCv~L%>*IfYJ4+8nz;gs_Yyqmj z;z?Vh7UfdCTm0{i?)6B_(FZl9&G~Yq5qk!EbsE0BtV(019_-}5`z$ z#`#MQpTDue+$)J2Z1deIf%Y?fjTKQ6-x8Ig3_vHN{MKw|lF~cN=Gmy8I1p}CZt+ERky734BcQM_7$Z8u67)%^Y3AdE<|x6}jaYs1b5MV- zinQV2a+dd^W0kDsMgZ#7Kw`N0MPi9ir8R`{^wQkkZ}QfeirlS?SHS;1_GCZtrMf9~^X>u9 z#EV%^8y~Vs8^rnpw^6%@OV7W;X4{5sd-dK%$7}lVo^RF#f0dR0M|A%C-yfj|s&Nvf zoL`RqDnJ3tl&jIE2rL>In+}g~dvi5PqVbZnQz>3@db~YnJo3{U^DtpQXhS;}%B-nU z;~dlB;mO);Z1`Z&JM2&x;n4qW(dBFXc_nE!i|!`E#*#(2MadidLa_lKeNzS4u7_zr z$hemzPJv0v`M}P;+*$$t}XgASt(1$cmoMMu@RotF|Z z>(5EB=!^5UUmn3CVM~26-a(%~YlVxX59ds^gd5v%^~fIZlsMj=TM4l2)HEFfCMKqW zr?U@HEslG;0^v8UBDJSfQ1kD1Hnex9<}=MZzaDlI$s{*M^6v#41VVK~<|*^9AIm&H z{57@X^QZ1KLh9Q*B(f3dPAn^~eI=Ion0}vUeg0}J&(__x6A?ob8%J}F?ZwGNxxK0o z`0C@aV^L9zU+Staaac6!GH$k(_P1tbrv%QRj7$(FNjdZ}&V4h2jX8NKoLbaJoy=|| ztLM4ygOS3VwTroa-SN@a56NHQ+0|Za&}!2;`^dAGWbxu``7(;Njx~qwjxJ_&X7UN2 zSxJ{{V9SK-?6@3hO!B~O?Z3B`7FRYgc`@Ig8jOF9BrbO5r`peTH_8td78Y0xXfYuXY`O;y*exTHa&LpL_gc~;i-dOsZ0wy zhB*Jiln>JSO_x<{db#MWj+f0On^w9xcN5$+pH%W35HpE&yCk#`bK}haJ|@!WU{~AI zqBGz7-6Eq*pPi*Zdy43_h`I)ZaU#>v|6qeMn328V_L_K`DAMm;MZ;&C1Z zO8a!`f!zYU#Q354=M5d6`Qdwm&b7V+Ukv#BA=uh+<`eXr8r0DzXouK1R{Y!W^OrJf z8UN|Xa)3sYt0>iB`}K$I9+azRt>zAX`l^u(!C$#!W_l~@3X#_w1}Zu7G;c-bWbB+V z*LA@44p>N?#lK&3YRp-#8>~^|(CYWK8VviH@W6P^yKGOmNL6E2>#ARu#dnffMrILy z``$A-k`>h6GL9f)1??WE(ZDMmT_dcevFs^b0WFRm$@YWoTomFaWPan>Cv<@T;ilH-M>8lJ zih-O*7e{lBEnk>IR;CoY;dAPof^}&`Pc0mwcA4mEoMzX%?98qHtKj^4{Xe(jS=9zG zbF|w_HWt7BQ05?QciIda-3Gziclx4Ay>~X!(>e>shb6UcM7%JW*&aJ!Wzi)bwIaUi ztTf@mHV{Z8`Om*z>38p`(t*4Cjq%y~|N0x5IQRwX7ZbicW%~9waEHlN``p76qH{i2 zm#pN`3=w!6y#~d`%_Er;(`)1`gTEWdxAX03PJBVUBsly3m9%Fxp2-YmSLRCi7Q$S= z{;-ehePhLjK1pADY56=Z(eC{!2XZQ1GeVSoMoHu~TFL3OyT95z!SEjoz}R8c)xwn} zZWI|9TByXsD;0?xtK}(Ui(=6^NuNuADmv!BZZgt$)ZNOi#`KD*S{s}%%Vb&d7k`F# zrp`R?Jh&oWO+@h1woXd^gB2jn664?P?Prwmyt^NnEH!={oOMBd(mY$;S9T1zm|R%CgbhS%gT)P zfJ0$!v?BmApP{d}F|3j9_{kof?P@))%m z8_~|7`}K#<>?nM+bdTX0&hK0^FCt`WUYmgyC&v1mU}N1oeiHZN+UTgAC%S|WR*Gla zHSNAZEw{CeG5vZSGPnJ6jBoFN0b~!EWSsc*htC*2oeXj72E#LRm8)G{0`9RcC+PPz zDidi>?kuuy^0OL`IZ()55a!)M5cPgU<+Rr8;-Bk zf^~mR?A$-xU_!5Ca_hus>dDOnfZva&9{p-$8qHwtTDnl!vVN*qTV?vu=@D>8?Q-3v zR$s|~`iNaj(c@NK<&Ix&{5sI~+lR(EN1$f;eX0!ZGqL`@PXe2$jLTE2^yb$@-1w>` zpE9p24dhISuE5O}aa%|KsQeRxAx z{k(W!xa(2j!tdtFuDIfUl+nGXSO3`_W_ZR!I{d#!j7yL?{Jj{G&G5Knzf6`$B_^8p z1LF9yupOmxO9zGVvFU{Z1B-unGp;kh82@IQS^V(VAD*>3=Db!)`k4^*3^x#h+94@oIG1d#v59s+SnHxJ+xhUmusu+4&y<|69cxOWDY& zuY0>f<6y>7UiJRMHx44oY`^Ni|6J@(b@}gCo@K|+ZiS-?y#vYNWcN?LrZ%0R800B@ zhVk_{ymJZ1Ny3%?$sNu*0-3TkZj2=NJ66QmQwGl1UnJhlHWbEFA$w$m zj7lLlz5jksrWWWT3#TXV$q7R{7p1UwNr&gi-5V@Nz5e0i(mcJ|er5h@-#>cjzx%Qc zpUjHrbo>ItKbghfU(j;_ruDS)ur_%KmAOtUWHmCRRWMW~(x-pWtRvQEr=!fMB~0_d zgEu?eMjRQZ$qXaf1dc!=L)}QUQUK*h2TU$?#5BF~0gZI6#VO$y-ifqd6$I%z(cHix zwUBr8vn6|c=}XdZH4lR7VSDLxopwjbFI2v_$vs)LENnM;V3u}i-N!LqD_>qb(yNMB zBkdhA%~~3MxQm=5f%iOhfDE+cbM27xI%T3|4h`g)Sb%)*d=ZNQ)o+C|OWeVpWQ0N0 zk;2DQK>^>@iloX&OUnbK4cdm2Z%dXxT_yj}2Rs9Sj$xn){I@Xv*F_m~J)p1|dS(R$ zg_H&D=F^^L_sBf*ecotX+^oXddYpU}fM>F!4EY$oINUna-mH=Zk?nYkp>dvHT3i>6@owazKB{NGd$9ke@n9PR!3fBu#=IP z0~D9rGjz2#@4X=$yz`rQNVk#Y(rfNJ5f(dYo#m1#)va9i$4P+;DCQTWX-xGzlD%%;TM;njbw z&wuuf&sA{bW-arhe93(-6Yl}Nw_6%&l$V!37c5A|$Rf?r+~sKgA+zCfLA?`_8S#he7v1`)gnulmX}xi8Cb&> ztYts_Z`KYpGNvrM#CMpis2q}`WHZn5#Lsj@v|Ba!JRmzNRs|V!LlUlI*3tfVq06&D zVBaAGn#surX}^=SUWwzf5m*ea0N!x{vU=lL7$xC!gc~Xe*Z;dr4^J*Ie`-96zO&(r^U24xaV+QBL9Zy}RU~(}*4;cfR z8AnIL=`+6zThRdD^N8un8FF{`q&zk=RGbSk=pmVmS50JvI{F%H{iXzw@566)D2)?X z2R$!RMC4Z;1-~7wTk4>pHIVW9>u15%OC^ZPzrFr_k5PW&ua_pBEtJQffw@VOyPrf^ za*hv1_7Q8C+n3kZOC^7!|IW&+1WS>#PdsEO4%^LjcL3D_pLtuvNdEcIGL>Cj%NxH< zqNqj_$SE!W#-1aoW;4L?=&5sr?2!ku!w%0lz95|D_|4M3)dsr%InF(U+}cKa91^#i zQI$c}Cj3X4{#B=`x>D}G&)izNfg;M$K~nvi|!u;_msWuWd@X{GswA=&RR ztbiRH<<@(T?EXC9InH!;OS^0{l9$}3<6`jq`0=fE6O2A;FA-N}DEcS5gO~P&e zMn6Q0f!OuQY0NZ4?i7OGkIu`B5H_pJB*14r+i?GNX8tV(|dQV1jR1Z*E4CED@ysE`m z6=Z#w+_-7Zj)EO4O30+_Zx~NHp5YF)c1cUHo!5`*4YeCC8v$K&U*HwYp?mb!|bGs z$!}OMkRvkZ=<&NXo6!$@@EIfW;zDy9tZ5YuqY+s&Lv{53hTb!t zS9`%=ZYersf;Ry;N+N;&{tK@^WAA5Z{q9BEPAdbOaST%k`ZX<@k5+m1fC5*l?Q+8f z$JheEcNhL%&}yXYi(?2N#%o)2YzY&7uruwyqloZ3WQS& zvTxW<^}S`{ox_A&E$FKOr?Dll9F&SH(+dW^gCaU=#|T)wV270DLp)J@(#%Fx>pM_|Jy(7E43L1vZo4M`If{ z0bNn{i<4Lm2!hV_rYtMsYo#!RA~SS_t8;`y((~u-(Xtz$6sTm;KEgN(QvmFU24c5K z(vhq`2*Gusvt3D|RU;Fn3%JTkwq+8xf1CX+_JwHgqc&5gZ#v|0t0&$*^EgmRq>*5I zYg0Muj<~y|Z%SCrL0@*8*Cy{YbDlm>CQN6#;duwmGRt+M4!a#a)MijP?2vmxS*#l0 zPh$ElN^UIgohEeLT$ zrcFkyC=RizL|@pr$G()QSm)2y>vV7OKDlize2D6_o1(vSHH)CJ>+&V(eRD-?k!3$b zwDa0jLC4+luyw2zsLR_}i`pOBz`47t2PllifsCM1Ks2z}ev96oLd3_Lv7JPuXaT1t zRnhT9BL;nzOP4G7`b3{L1hA%7fD+xrC%i%qfS07_kzN!FiDoRqw)KW3bFZ#rMEOb$ z?Gk2rqR3`i#Xq;tBzC>aud}5Ds3)**0#)6ju0=lh+Vq(<9%3r^Sv|9UmXTaF8880`<~Fx zcP99igTXK9?a!n4b8aq|3C^c(cYJVrN-6^!TuRGr&AI+0tfy%IgZpmn@CzRuCMm?J z!WiujXC%%0vJ6^@>4hVp0I~GS&j6Q`80p;!j{TW}H#*l_6c={@eVnb??nYr{KE2e| z*_HvLIqE^H%5vHOKmwUz(wjanhd<`e@3NFxL6}Q(v;`Gy#aRVQp>=4Oi1hZ46U_R( zx%<-lYtov_xdlfUpOw#NlrFqtv&`y(o8(5&&-T~`sL~)$M2Ik0xk2RXfZTZmU zzE#Vy;4eDzTB`;BWIrvLz6_8e{xNST*tPj>f2VRkSAnnif{nVr73*|2j!`T#ojWh-Dr6vFNE*LXJ!0|){lzcHd1eP#T{ixe|RNrAsufu1$TpY_{#U$U2l z6k|uj-enq%Rg)RUBz21LY;(cyT`F4h)S z+p~_BQ+_X@$_Gtd`vD*K7qMpC-i9di_bxhJl^79p3u4K1ZMv=S=xOnyO_ZK$rl3lPnGUimlpLE0cOvFl?Pnb3G%I`@TnBjDImvTMZNIYo`e*yrx{@c zL%z+VwN4J!BuFT}y_gbb3Y~?Zu9SiJ2Z2N41j+;ZWl35nrl2lZG-gYAkowkpI3bah z-_>2ozBzfDwMkiG_3PJjO1wj$05lIe;A&!$VgWRVWwzIA5JI!KtQay1(d^9DxgycX zAfsVw{1|weA^a>!ze#0OR5-1Ue~J89XM{{7QH|(0{d{vdGTDBgMD2UJh;Qq~ckZN+ zn$N|SP-GZ?i~AohQwKX|5lOy{T8LmNaxWIq*9x#!lA7BUV|} zREhF+YD#E^F>S~mk@6@i!PKRKX#1nE=?DJx`S;eyWkJnCzIoT()%qpemAQI&{rsMg zk&lD>mHp6Ev-V)xXIkh_v1{5{`m(xw{uY0(@K05lO4_uv3tQp7g|F1*$l2)8vWILo zdv6`Faamv_Cma-qVh=GUH*O{}OoOUYr64i96jD9Qc%d)*jrGSGDN`w$tx6!_8(R|} zv^STAbq&7)71M!85j?d-yQEE_;^WQay4@AE_XDD-+*vBEMza1W=h-EABzS2`Ri#Da zPHLO~A(jYf8+A^#M$AJ~RK4X>VP@u=!;bW=Z(TVSINNrxO)J z$Gys+x6sb&N-cf3*Q1m$N=LZQbV*C3WT~uIC1fR^vLW%QJ*_Vq9{_`UJ<$0BdDjXE2Ip$$yyv>0tkg9W8s#J% ztTa(gqRQRa-@)yV6q}XF_X;iTWNXA=73H#KH-eX&+=%n&K`Xa4HfA2|Dd_A9INlba z{kbNphr%2mv3EgrZxSdP z#3WW>ER^<_k`<6<{c}w`2o|l08W^)qzy?d^qC}4FQvGH85n&_>z|+n9-F*+r zz|9?-2!ARaXC7dFBQ!D^x^gG-{*<}DzB=1WLb0!Y-}~#E|B=FHhdlMKWgR<;O1gCg z@VWeprogZ&Ingu<5bl?!zGcx_x+N}kB}t}p%$LIw3QLs<14X_{qMVKMYYiZBzmRNjiubSIj1 zTE4)~1qnr%%EdHVtKaACS|k<>sB~BC+U&E^2@;(9D+pP(S?=G*18yD71_#uD%f`a2 zm%obC_R}93Och~(jhyz%os57nMc8ZPY>z1Zn~vzPHu|c1kSdd&x=b5-2b5%MPv4^Q z9^7+IUTbB2e#-}E7bR!`(z73=@~Ebn)>QWZFn9E+|0dxv1&4?RO)E7FuNQ;%ryx~S zv)y#P-@1GoX~FbyZGL-u59xUW)8Sz?Tw-rqtstFFgw+?W?1_#zO@PW-t&FwRC*gI` zqhj&xdPTFv-D0T0wb@N){VRte=C*(C^a$Q?-LjSrbwWmA`yi>bsua1vxj|Ox?p~(v zN6=3mPl%Uw*aI#A$E7_J9jU31CyH$uOMb1g6L%2d63#h00zOjNre6u{G@C3Iachbw zT?y05ZE=-NS#)enz?z1(h2b0u;>x_{fDlIGgZOkyikh~;rc~GaT(eq%#(?r+?p{Bw zfTbph*q+Q~35@fd>DGuvl3gI{O;tR7iTxvLawYD)I8U}>n^$It&fuzdTTD-oLLxe3 zC2D`SJ|dWhd7eGejWF9F#Ue#b)UI|ndKGLazF0`_W|XW_2Fz*8=B_o)gP2CjKYj4% zS=ud}s1T8N3{F<=WX_%dUPLHYHksr|;9ELhYDet#S^a4EgAXD4eEc@^3ephAdFz!} z3*cM9(hyBO=_|L-hEH*SO?VpsCnm>6hIyGlhsqw`X)(PF9d#j_UGY8N zYsc^8Rk-D6#&(Svp;LL-w{SkHTSL|U!bj&qSH|x6>>|0fmD<87ok;8xFR1brtAFUz zmU8W))YQgmlIKLn+T$aQr8jskq)MgFjRPM3iotY(?6nnA8;=W-_mKB7pa@Y1X@JIW z0!JoYI3zi1_2|t728qSIWv0hHDkX{54u};L)(&}5m#Bi#^i=nGiTpzoql?i)y4dz3 zA~X#iAl*KvQ`QkaQo+>BQm0a{x4B2&jtC^IgS#!AOeekWg^wndQNI#Qui2GN!IOMCGlU!{C(89_qoqSpY>^a){qnjGTZPHK z6kVpX4G|oE!{aAZBswyu%v&WlhrZfv`A7O)j&wSAeJ9K;t< z{qTXgri)%mn;KE5vC(Cc|FdaM-P`h`Jo1F(y-*>^eS$fni`X`$aAJ3l*q3Csf|iOk z1R)ChC;oNXEYCvgzRDf0-I<^MPlgDW2*rNoJ-@}0Vm&fOA&6Wc0m76$;+#P)G#dSY zb_>eib~`4;Fx}SWi`cd6HoF!Q*B(T9uY%hy5&j8&B&Ea@G1Hs5Gi2M=PhuW`Mz~)m z>%a2?dMMOVyYhpg9@F}oEU%9*UoqLtQSr$|l|Uwo2vcuk@E-dfZN0~C^={`x@^<4X4qa2|`E#P58dj8>Dq z0h7ST73f!ceSM+SgyKvE@vCP79d`7{i08o6c9aDkz)%NF#EdrNl0(248|)@#3lYxs z+8L-r<05;9)a{96EAIBRsvP=-9*yVu1?N+sB&)ILw5X_Avr9n|Opn&9ae$}cDZ37B zq&I|zxDTXwBxg)?j;n`~A5=uZe(~E~)6K7PtMM!Ie+TN_cLoM2HJ# zS5VG}1wTl&(DfH&ZDNc?g=i9`*vGpuYf)BvA#Q0KU4)@kVhid@uYk`_d>}M?15V|R zrB}z_*>9fR-r3t(KJUn~tD};y{Nt5obGKl+8M~xQ-k?01=GfjY0&C(=JJw#15fh`m zLMy;H)e}t9ou9IQsB)!d`JU%vpKWZU`<`>@9$}ss%bK~TAS!T#ueXmv@K1%#s`F?| znET&c0MRMCd|B0;Vq{P)pwjP$$rCkfWeVakI zPo|m)*|5F{=&`J2t3X}!pt9K5A<;qKY8U*Rq4Z9F+b(2+Mph%SH)|K6)C>V+$PrPP zs{48zM{z$~W^bIi^1jv`&fCcI-NlEzE8&8v+;;H+$`(r1GeF1o_^>irBE%GX8Pkl? z+E;DntQ0o`h^{0Mx)E!XZFaJa{WcC8df8^DC+(yb=T;=X0{|P@5 z3;PSeTYaKyuSMH6SG)LeLYa)ZAkm}J^x!;_JCh?X}gn|J|1G9PsQC` z5WlW~K?OZQK9I*~amt)lM`{Wiopu`a+yHH&F7>ow*?eMz9k}16UsoT2Ua_8e^6+#U zN=xH4ViTz}QE63Tb!2$g*iZUB!LROz-PJ9_Q)=^b1Yt{pe)+~^rOvrwHw@msQn^SV zq~dioDsFSWdlGOK60EOXfs7TS4B_KRrch#f8wW&@i_NO}hvPAOJEG)DQ-s2_NOs-URiOlkkdj)+w4Oo#%I+ zh5%9-2e{RoTo!!!=>Q`G9hEUcx5M{M?aqq9x%A9ykF85rN~%p@)J!W6?~K?Da*aY& zKQc;}!f3io*lYsb0nvIoWF<-(uGM;H#P-<86EH)}#ivLrB$GKTRM=r#D!MAS z@*WBHqJ?;#F4sRp=3`b^_pf>E$DROshwtQ2=Gdi^qyaN9oa8paO9ZAqJiNNRT z!)FMhmh3~H$8j}yIQ6{3k-W%^VHC&bZ0G{-fyj*6Z8+6hz9sY`we7a#KIFt<$;#GE zs^QPOKXI=_Drv3^aH89dP1?<0Toh_~Nl32?HV^0dV;LY|mbr-iGpjpa01=J5_5?EK z8Lk`mm*Cnq_vpT(kz3?xV;?fA7O3K4kaiw!rnW&oUEn;8kdO3+dNK;R@h~4WdkA;y z6o#2~=Ih@V8+Ivvbdt_?uBhT%q?ZAtQ4Ea#%SYo>K^HwdKWZC6aS4e>R;_{-Y@)FC z8l@O<*c&Dx#3PpH_%rh?=~JW^U*?@|#(iYoUqKI#$yp1*C30jwQFcRpDLzWdDm zG_5@pI=quO`bq9H{D%ml>-o<5?1V$6e0qtL&qQ}O^BzDji#PGxDl5f(0GXc4O}#s) zfsFUYme}ia6k%T=t&dQj7e-?;`4&OXoWk=TBswiJjmXczR> z{t8D{uh>H66}jGYkJO4j8PAov3Y2b%-SgnoityCL_Dp&T&_+Yda0XTHN4WBHItH_4 zd>^@p6s=z`Z$dbCOnBeQ zqh!JNFYp$tM%DgKslkVu5pp3!PymPqnE_Kx5u*}+nhk$>#dVDW`RWL@1tJKG@<9f0 z^Ta2;jRCl)Ju3vi>9{oXNdWr6Y!3gHXa=#Tzza>`X%zrDpVcVz<11+(F$NbZUt39t z9s@l)cdROTG?Vh3q|3Us!DSzsIJrV7C-p#_6%@7u zdi@R9_v)g~(-i`~Tht;xWEeNJ7=0zYe!7X+(yM z0P|v40#?P8m7$`ZJmlMv$AJt%w%=rzOK-c*`dAxz3H!mSgbJ^MW26MGcG4mMmhibl zW+nX8_qDDI#mr?9Zp_99>Qp`2L18-(O4V{OIOz6L`;`aYPtPWD<$Ag(Jd|K7&+MT- zsqKH!#R$>(s12)W95LxtJ@%Y?_JHT%VHC2ywJ=d?p|V*9qb>Ukj|bC*jesI4_P(}i@Rmnq@xqh7FMjE)6sRsVK(hkXV`0)+=&G#dfNG>lP70$9WVu>kK&BuGpfC+lAQMG!%vggs^PHZ89hc{v8DavpNIV~L{>y)h*41e!~JQupq8 z&>_$x&IDL*&W}amUfg9T=t?h~G+MbUD~;QA-kMGBwPS)Y2_gG7feD*M%U^cQ)K>l5 zW7r`qOT5DPbbd@0=CbhyBRd#fMZ?F=*)FQ1a2UltwCBY1gzFoow*@wS-Ksc&s|tE<>g#J}gj`!LOJprZ^c z1Trhk&oL`;0v&^g(Gfb|4rrB(W)>jD^xE<<UvgslgnsVg0d=k>&;HU1Czx6f1o7W}HgXZwFl5y3bTO zYs|wlX&@pX;V$cG!H992-tlo_Bq;TgX66D^2#Fh9Q$0DPJRQpu<~ZVM(>`* zW?95p^p^Gp1_Yr+0InvA4q`=KERs?Rx&p*h-xAWBBf-^TQ!iZx_Zum=1^qm;}6NPS(TI+JQCiS0I|Q_|`I?_I^2{)eO2hJ@a8 ztuUK|P>xG%NdmnFRq%YdT*jUzEgvcPrbuxMw$FGR8HCmilyj`+KS^VG!ZkBG-s_dU z2^`LA&W_y`hNREfwacodJ{1Ot6s81~sh9C#f0Ez*d0QGs*ZLJ5z*?)hC|HzZ?v3n3 z+Dq{KNY7sVdO{_Sz!<`akkrL=FC?&*d}q3p~hZ3#ABqs}JoEXo#|evs=g$%Qm+%}BKfb702}bFY5O3Tbe^tz`PPObqv8Tx(;iC-S+h+S zD)#qYh$?uR_>|SQxO00DP}hXg@FcVx;+FlAIt!m%E&^Lp)t&Ta&9?S zx_8B4HW7ndQ4$>%QagNenBW%rJ`?^`O^J9-CrHdxQhFIU1UBfRu_ThsgZueE)WCt{ z7*OmtddY@-UJRp`hnEnXg`2SuWR0-`7k3I_s3h#OFxTm_xwI&v9<3B}(Wjqrg%)*m zeP<))vUEht{PuK|6&#kL6eTrGMStc?-;i(dalyw{Ad)M&Lqb)AA$W2RifLHi4}K2H zSqAA5tUBpEoA3;uHd%vI#W1fA&?o5RQ32kUsrsQcQDZOSRdcOUYW&=sD=G*9nGl^j zI1v*AGBPF7pm@B6luk0y6~IRsPzp4`^C{i*yZsT7p3O3W;XMF+7fJ)t%{NdnoCrN! zNLGu}$x;mp#W$xcll<3zap=k|mDCMTTH##Rlm@y~ky^;XBZd3B)6#rptY0!2QoOb< zN4e(E+EN9lu+9Ni%8zpM)sthJbIWt{p;Wm{pE02OPi<-AGp#3|fo0s!6Y|9`Q7WDP zf+|OF5Ht)egWhJ1KA!U@c8Sx{`zD0dMbK;11{+MY+X7%J>=L$8s3Ms^g*9s=H3e?1 z8x}bh;V{r>0li3mJ2#dnQ%p#qoOe0NRFwe;EclU`>FL|givZ`orQ+et<%7c{EP!MV zt6b?3T&rWVx{&B?wXRtVe4Rta#qeIYS2fdwaXO^N1_fq&p0)NW);OB7+-1s(sD=$= z@0EgU{he=zDa{1Neg!tshwi zwWbSAzB!CbO$FS^35nJVq?{U3mdZ|t1o~=LS(#4Yo5WEpiF2>l}nc2Ao43%>8p+a~Q z9OAS_?`VCy4l)|Q)Aqx@A%K(bQ#w=%5G;E%W6UCm+#Y8kWLQM8?qjef7?zh13jkXR zq-=iNXMT!h7MxLF?f&pk*gvz{?GMxP(OtWFv!Nq=n-kX7FZL@yLK2JrGNG))I74oe zz>a|JfXjS3fqvnz8J?<25jCjhV9)Eou zKRNaW_?c~pS=7~CLzZfLkRlMk*xn$N_`$9cbvaOor_LM8nLxvk<&mgRU2=0_T^2>^6o zKemhCdDtM@2huJKttz$?L8D?z78HX8779u&J2j+Yv5d^VrmKFZjf5W*0BHL59qr0J z60JLc@2s2R0pgAZgAlTdS(7V--5ihzLmm4JgmU{{Uk*+@2$GLQPG{! z2g%&_7;atM>w!q;zhH%AXbQUYpd+9 zG98Me^m!BXnAA1G60$3O+W7IS`RNG|<55!NZB7da?oqpTF9w$!9SmJF>ats^-1^3! z(;wes&!qfN^#(Ni1&Hlb@*T@ap3A8@v2yR>@I7#Fs-&gDvu3?i-Y~7L`$N5udon*M zOb3#202z88;J6;AOaKT`<|1gh*F;#br$>(U76MbWy3&3}qY}ZOZZJ{pO?_F;;Z4xb zQGaN91Zc@bk1$9)RXm&09~}6u=s`DEe8)hKLcY$XOi&iSnUsO1p`~RC+8XFXST_5L z$QV)+4M}xzQKb9P0@9JH#A6rf*PiK+Sh0{=4t+>XX-l1DY0u4)3X=BOn$W4vpp-lfByXu9UwB9PX>EQ|S58r15!H!s0vzwkYy~}i@og742-xi9@=d++ zr-0(hn4_>lstIXPSa6a(TtKt3fanuE-z37giwv|Vk?$U}@a}?k(@yRl_Z_wQUuqA9 zGC=7`9e(OBfZ<<+l+oYA>z$Msn|%kog)90eaPIp6lTe_BCdg|#r#}n$`p1ApCxUEi zDd291H+^FYzd+ak7IcW5Ej5R07WbF9ISgpwUWO*|Z`m#*Cq;DC8#aMirh<4uY(non zhPZB!&KfQ$fMzHnZ(td>=oS@Tk|9>C?k! z@=R>=$9Iu^t-c-Iq6qAO=%RLz4}bBGedg(zAJ`9Z(pT;F&!lJq=e1^Hl5FJjtC-Jo`dKsd^z9YO_z-nE#L( zkl;EQGuHVBGG?zR$A7`>l|GtgXc%jq* zmd7N7bxxswe@0S#a<)-n1fkg58OOhjakA^1MD<+K6%q8<}A$)p! zV}D_o`V$kfqF_~0aEe`x5utj0`pXIZuW2FY-`=3*d4FU2x^XjwS~JVH18+g&1gf=n zkJ(S-2`#zxx@i2Yc#27MOjNms?HpoJzH$IrZ@*lIPqMiZlhWC4rKOCmaXnA-2*D|e zOL`V29|^b{OF;r@=hNNzSZlc2F?bV-QQ0#>9!W3?HT-N1^ksdP5nSr{1wHV8&f6G} zn(ItgV{_%DU&_lz1IZc>ocy7x?JQf9%Fbnk+XcEuZ8(7ZXqJTD#T|aGmR+n8C6&$d zH14ROk#+fDo+-BKFeqHvV$d1K_?gchQlSwg@{L*p6SjU@qhobdaY8e~E4(bk zU}d!0AU(gwbfxg?afPR;g1RMNrU5rC>loTi@F%h)Hz?fMD?3cfskx*^z&iRDST7RZ9d%&rypln z(JVIc?ugv}ms((Hea2USG@9I5t;@+;z*r?dR8R;?nAJv56o0lr%+=cR>vBXMQ8F2{ zDAZ+ddS}e@Zjez`h648Yf9fhdnB434g;!|EelLUzMhfCk^VB~?@(WWsq(C}hsPzX< zhIlNh?IZ^L9Rpb=Z58}kRg|7@Yc5CFJ)~nariEXc36wKO9J<&YY;}lk6ZH5wrFd~Y zr{SF6Q$KYX^YxjI{<6+DuJ>OtXsW#cp6o#^w<8$$BjG@6R@rdZ^Ua}hjie1p7mhvd zc46UeLY<2iTnMG*YlgBq-|hmRsBm4*> zrDhlaSne<|^Zhw3crI+rbez|yk7wnN>Cj>g;%7OLa_sCC(H4&!L)X46XhA;GGIm@?>-ngJ~PGv_7qy6;GJ zdLmIdU1ufOtj5Y(>e=_)FUBWi5pMI&f7~^hC~3bo9+q`l?_>4ar)&07Vmc+3`OAmK zOm^R|X)F570WbPW>D@#1q^__Qugnx)m-oznbJ79raV7`|qiG+i{5;TkRLWtZ1Y44+ zRS>>h>s0p#X4Sj_B`^YPw8woBJF!E1DJLT{P;SG!QeQOwzXHj4s*sY(y*|D0g*v(K zMq_Epfa*{ZM|oAhglLxI?@mfVQ^zdh)C>bgxnwnK_$F(vyu5z+X$Ot`>KcQ?uo+uGuqCDxibC6oGFJtz*2uLaE}is%(p zU4PgTIaj+7h)c=t-%5}3e;`)Sxmpe;(=*^%MZ%~RWE1&0J?A@*=1lN&P&efG6({hy zD;Spma+>0oR&7%fKEs=?7V3PN@?k(!UX*Bdnj%Em_h|YswXvE1XybP}fw=UkwcG>a zMt6*mkl<|FeEnV=PF*f1f3G#Ce7EV|DRh2;5Z^=Oh*mG}oM_{Pz(Wh98TYu%?;d?l zg8$I{3Ge+@P`RU0AkI%W8aV~y1fHIzh_rOIyY1@uVXZw!Zd|>||3?D?z32IySAWHE z$fZ=2{CBy|%Utta={c@&2|*g)DWR9?G{nwVbS%fEFXy>efCEqF{`+VqSxcTP$Im64yWlD0_3>S$ zjeqntCevAk^JU@mra{^tteH9Q);Omr7|wuHWj>(8Bqudkme&q*OOY%bj(QLejP%(s z)eMCU?s8`mAR2 zH9=ugZFc<`wjGAK$xxkvhlZArrl1nTr)kIHVAW3<=*63D8cP%f_o73YkF2oC#>v?y z+!b@p$7-0h`z#74BtV<932Y{8Af@U>nq{+Iu z)X^hbfh)wUjaE{gt*r!h@=>&GQP^|!jMZ^iWGXN&_8%69Wr_HIpWRxy`PJ|8mK3Nd zvI&2?wtZE(?U>gDmgmm2Xr24zc2Y=lccCOP<+b9s$90ZwWr=U6UamWfSib|V{G0vs zPmtUiP9skq+kCiTZ77#J+Jh3#^|ycW*eSd#uTE<@T_F3#Jb6#&snE;2#ASDS?q*s_ zgCxOLvGW?)=O5kK3Ld3JsvVwDo&0bY*3fyk!58`jAZeF^*HtSkW3{=<`HJR%m{+qf zc9fa+ux?@xNG250+z33|4KgTM4!%XZuHIR=m+ZH(TF+5G7X~s+AB%y&>Ej|_)7Oa3 ztEPyGA6Bj+a?RYQ>YnccQ<}n;bY#Cu2hcb>udwU$Ng`O^8U}2opMI%h>?(ePYz#i9M}C; z!Sn8J`R2SxE=&0=H-KFx8!{`+-MaR%>cZG^cXCFU*XOksQFK~~OY`;muX7zQFRpnu z=P4Jf)LlW|HhE{b)VqG=@fjw~@KQCbtKZdvXIcH_8w0$coaaRL@r7ITl8kx){-kQ+ z#c+y!-=ImHV%t327T#%EeTUz>fx1w$)sK!xm@?69uaMHrR)mK?iNRskp*SOmb*ydO z#z&qAU7c_aT8eOeEt-y6AN(pAUU!~<>HZ>!fwsBv7O8YCb2O#swOSpXe@@f`ahw%! zx+EbdF1HED%_R5-yE8ny7_V~e|FHMgQC0Qp{-`1-rJ_g(C}My#NQwxEf=Y?7=mu$! z5RgWYP+F00kXm$iD=019Dc#b2pSgc$pX1&e_j&KQcib`V82g|1)g=p9bAIRd`#hg| z!bm(!M-sIrtdnm?Ea_up?7z&tk1@(yX+zDXBQM)&P5Wfm*(@2*!8^89A zouO;4VjG$*Z|8&08%{6VbWc)LLU*|WI_MYSaTpsQyd%D{V{)n`K)@207da^B=UxA| z<#{_spphix^CV^5Q zW2m9a>NkH;ancbd`d_NPMfRZ3VY82>iK9o^d4;52aK)R2LTd%?@sD*RHS6 zBqlbFrclRR_Tk{?OcAjK?OP%fhttIu(coE}a8!7I z!1fX;GSkpEI=o|T$;YG)4fIjH>BRd9%J~DHw}W7C`ua1 zr|+Vs?`c>?*`b7K2EXTj<9|%Q$m8Z{_GEsA!enV8(*j?s#CrrMDph~~;tDl`=o5}3 za;ZGZx*J`gYiH+eUDnrceTrPF1(h&kWpPf&0*z1XC7$U+8h=qOimSlPpY5hWiA`=J zR>X|P<~rgs>hmwWnQxWXgRq%FN-gJxUiLbS)`@j; zG42q%1?VZ?H+JwNhS9Rh?G`Ua+x3|b#4-n8{8=LhfjZsInU`d&P_{IyW1>+qwbtl+@R6WV4eQDiqGP)( zbp{*)Ya6Hyw4V5^i0}#QLQUrp`_Q9Ko%!Syml)IzW4g%WZ||=W#9kH5!Bo=2RKTuC zu!4Ht16tyH4nEGzF2R`B={ai5LDk2v=~N@*YKM)PnAL*#c#3wfdof&W_3|gwn6STr z%7Y&MGsRd-E*#C&TZ;719fjI&i#e@N_vJUx zrLx?8P!K4qnDVqa*3Y(2Z1I89Xf9)_Mu%+jsG9F-vq zC;3<9+bW;U#CH~_UmGEn<66;~!akkwB8HZ42enRkD|P`HfEIL^H%Ft&fk$k(%3SZ+e4c-wV5_?P08Z7@=%-0Xr*D`3ipFE9e>Y9J zb@J$bAlblpRN-GJG{m@}L!*f>ocus{TtUK8jYIG#RMV3-$6tr%q@bXyi=M|-TRr^x z&&wg)+ZF+XC*CHVK8Qd#OSZ?%Ki;-8eW2j)hOL^1eC^gx4X@;W1!FzVsRdG#YRk5(J z3Ip%OmTmg{XGhGlqV-Hf_w`*zu=5iQh9;LB=#09RXn7Ya2WeVsAuA#7ht1i`cRv51 z!_-=v7BKxlm*kxZMsBXTR{T0*Dx1UP&c=$~(-XearWLD^>J|p@B#iW(@nIE@pL>~4 za{G&~<^IE&yE6A^-h|d+Pqzez#?v`l5qrf z#;eO0R3mz+=rZ)h-s`1k|5IPseZO!vo2`EIlj00ee-lChCZzDt18)f zvEy(n=l2iau>6F5l=7K7OAGjZYe6#iF;Y2Qh9iZ*4lI~%OGBmgb5#jlYfMF>E~HcO zxTg6m1_f(J5(GiOj+6?*=B6nSoJH_}i|*z{ri@NxKCx*H4h{R<9lf>~fqY>X36vdU z54M1GdH}9Z7nz0-n$R!Ry%BV3{xV_-7*tdP<|ng6>V=3NQ&_=paryEUPx_4H&hqLz zE@>#u2rb{cnj6nE&dWSWSr?V`&YIsg!W6li2}6vzub$TU0dv%_T;frwiL6UFA;?ra zGll1FJ-yjDM=VSw7PA*qC8Wnn64+fFF~?ZGd9)ea=UdX~uFqDE&!LXx#h6WrV|IW! z93;_%Vr-7qFA2iLeI(a$>C*^mXJ&;k^e^|g2cP_eci>CLLFc|FRmGDo*GMz*26r@) z$I^4ire%fUW|c}msT{<0J-}c1MG<%X_IywON+bUn9CoRpPYsorWH`4kUKI10|5ETdgTQ5 zkf!#G`MlW`M7>FxqkUyz+vgck9GuGo^4|*|`(2gUYUNA5*y@;ay-Ad4PJb{!nQKWS z>g;|YM(nRs=XqIVcbWQ$w=UIxGa+wXT;Hqf9ZR4@VNLHGYAk)dE;OJqd@ES_;t{#a z>XWRfDn`DYy|lrS=DX3Oe9VMY+|RyFAdre?!uZr+;egaX9`nC1&cClnJwE43$d21N z&)GxKg%!4(HTdygOW(F#OrFf1^zo-ocjgX?_7kSn(t2Cn(jUe+>{+ExJi0G>{KK%l zj2AAPa@?j0ucbd4%}oEvdtdzc_a67V{@d3S^WIdC5IflLa{s^b4V}BM!^4}etlujA z_e=F}fBdm{^Kb8n5yB(gH~tY^|MoA`W52!cPj^s{5l$_o#=uS9;|sPwz3)%o`ahTN z|IxDf&*l5i<@@Vb;6D%V|F&KJ+wJQ=m+wE9@2_8h|D3#kI`jY1HT=)z`_JY3uPgAR zr<3mQSpa{bDE|LJ2_29ZM7Z&%3flR-jJh)xF*dL}dc5V3OXCKBr|*xHjJ$Rc4RlJP z=Kv|MwdppPBT`?QfN}pk5T3dVuR-zR0Y>G#IUdIJTHr7!eR+R ze^wghHgm=THq81Bpx+TU$fuD#>v>_2I!4$If_nrZ-|<(t1rLpX-IWL5ArbZV;+To@M~ z_N3L8Oq8eqC9d)QRuDE45O}=FQT5Z*L8tP_+rxeyFdL` z;Pch%AC6sa2uZvGxsV!QwKpqi=bul@l_)#sy$a~+Z3<9e29|I&=0MydH#yCM-vMzx zB2(eJp?kcjL<%eQMF_+B5 z^`@`y$|&_r{zfBvr&N9W5MO6On34Gh?(QAv$Y}iWCN+-5gaN*o5PV}0H-Wxzl_ETS zc+`Zk-W;B(FKx^YIBzaAl|n{nEkKP*MLZ{^$uz`W!t+rcHwSk{g01Q{h=E_;?)!K| zCH9yg>n<5lB2GtFKU49~kES4@c&t`41DhjGxV((wQzP&_ps0`ffI!xt+S<}~8GVe{2Pe4%}gT81ab3>0mJ3G^a?R~+Zf zs0J~FTtWL5-`)b@Cg{z33!|Kh)MVu~+-zmvJKzjT>suKWKqB0IgcNiuz2;g9w7?hb z4vBll#Jh|O$~x3k^NRfdEfKwK^c5q$FojtRk=Hw6%#Cb-A2;IY^ zCHC`eV*>q3+$pjH?iR&?l%y!KW!bF;?^3OZ1HEKf&6a^2pO3wGl{o6|a{~SD3 zqnTQN`m*GQZ%n%o2KSUTWFnev0}eSSZ&$Ni0_)o2k}%?ftOZ9U&3*uS0w4Rw^K3Sm zpSMUwZZ(2Wfk}489xy>NBjZ>%;)V+8_c?jbZw0EWS4^IA!tBB(f#{dz9VW9~hy_Tc z7wgeG)y$5_lS1o5UYZ9f=%g4DBKnG6KwTdW6d2cv(5GLTig?u`+{LFGVOD4D(9062 zfjks)L-jL1;%i}Rqde8@YHYW;aGAbw379XR?pr?+ELa0d zLr129K0QE9`0FeX2Gsni8M7Aq-ZuS!y^=m$PBGt-ky9b+2CI6F@l?HqhQ}SY0LE$= z!7XJxj1&Z_4COfF3O5*ntlzX=T`>}=*XcF}t5GHs|F~ zi8hh^K%PEfZ32p~xzOH!1Gdp-X3q{xnGS1%yp`6=q?huGr=v=RAONObn6!-62RAN| zLgMb^r>v$sjh9~X_&gSjMb(H`S9XSqG}1o##Sd2&={hn9`-w~ufrvgPp2f)<%$tR#)d)6+PNu>t%_)itG`#*VcczXuuWCYW- zm1b|9*_rPI`AJnap(!v7eG~Vqc7h5Zoa&A}YzBQsbxTMhTcsZ<>vc(v;^YgRi5JGj zscbV|;~r{0@9dR39HyU>p65m1O^V?ko9z^Nc&N`84dx=P%|OplIH~C=MiADnJJZ62 zwN=f)HPkDLia~ep^^m39HHwzioM%y8c>v>gQMmA}RGq@}V|zK%>Xa1%r}uavSig8G zldCNbv2b9~8)v2U2dh~1B`5}?8w-0iy{t!I0o$E+N;T+v@V^&>F$p*Tf9lJ9nRtHe zlk7}PlLcpE{R!ggcC)S^kYq9x$rwk&5vbJdrZ1?L&rU}yhnv}z)ZaucW$9)hzT8g({O8TPvSCkT-ihx0JT;imICs#p zx>&Z>VwTU*EgwHO=bdSDXn*qL$rtFMZY8rqJ(>g=Yn;SPC_Tnti}rHob&`ejYXP_U z4>um`gp=NR@nn>lL$GsDR7gX)re;!=T|nE|7b*Be!aetDXgCUXz$)@36VpU`qJIg# z!>nO^D-?%R?sK9f3&2J>lL&P7vpk*1>$aS!zu&~#-Cp!sN{eTRSzq-BP35zlHtXk< z(|>`Dz3orJvK`)Is`!UB^$kao_2ac!M7$V&?y$D|B3+ws#zf5P8*@}2@mB5Ibmrg%gmDv)k8hk^$zwC9E`PJZ`;*5Vw;geiUGpHv#;|oGses< z!vXx&z=!PQwgn@OebQVx1W}1?rVUwih}S~J+NU>!qfrM=q{u7OtypATKGNI4k}P9p zWUa+zgj7;Bt$9dd$GjpA(O)f4vazGBN6fkKW(G>z))=-KW#e8 z=~b~tjz}Fz7}4C!D)pX`hsxhVTI9+zw6E;_d%fS^{xwe(M1Sd9{dQBoRcpV!Px8SV zx**KRUt`jA&)c#0#Ep!bIeNUS+0jB%Q6a35DQUd@&dbtnxI62j6{m9eENl7 z*H<{*C0&r%%5u;HZ28|O~>_}2Sk zMunF`)oRX~ybTd$t_Z{3&%5PFmJ8mur@iWL&uS+7#~2!bPD?EepSf&BkW*zDQ|tXT zN6Wl4x6i)O!3(#EWB(Z%>WUlrB9&OOxaJH z6mhXqQ&f+igRWbuI;hGan$H14W5%4Qrh|uZF<`zcU8&GoK4JtBG+R{U*j9Mn>H55; z#jGY1<*(S#)4bG(-gCJmb4!+bztZZ6sT^^G0dLS)8QD0{0lH;>R z9GBo!iiq9LwWpRAOC51_csx^ZzQ+l1w9=zDRn{6ftQgKV^%R8-9oIYk(`$+;aH7lF zj@>st{`^m75U}QObHp*-0EI^cs6=DI>YkmWJ(*u`KMUvFMPXkG1*?ZhM}q$|XmMo( z3n?|W!Tr=|Mb#A258GilHMu>atXj1zp7Td#4nkdUDv(ZEG?1}lYduu()2!X>w3H|# zQF4@$;@B#fCL0AmxZ)K;kCkRiJ=TXr23bhEjo^GmGW^{dL+(OYk_V_5t}C^09Kl|i zyS#8uEs#Tou9@d2H7Ag^0&ZiSQP41vV(=n{mNfax0aca;pw~IK-_=j2qJ+*!EPw+r z;d}P(cS|+U=Y-|?wfpNA`z*lzt02>B?hx`Ua2RzrU%B#zFZXq%nJ1uQuvCLAF|8Ak zS$hy?O?0Yi@YIg*EyKF&i%51gr|_D?uMW)Ylsu71WwMiPmlB(|8lMr4O2mrLsdCB9 z1fMWFjJiMJ%{Klr<>El5S|xY$&c#a!g3LtEv*XO15?5YEJpXlm;uknM=tK7^Gcg@H z95YMi&;p}(IWW3V2T1VG_+9C#B^t^o ziAw$i2egB3G;b23`Q9eCg0{vL5h8RIEwM+1Z=h0r=nr;U!DmZ^tv@{g={q?hZ4|F@ zzYr!*4%3ytZZCln)jEwhvLblz#h$lMoN(8;Nb5$NrO5Ouxr;)=G=bI!JE{@WpJ8Su z!*DBignSZhsQdnDSsnY=WSOc_lXR)g;|Ww}Sy};^FW@?P5g`}#qSDo`kpts1YCOFY zE_c2_mVprm+VPj1Vd^E9aKnsZP3;c*6-MJi@Y6U;u0RYCX>G`k`YNnD%7;-YCJE2b zYweZF@h>&Oi#NN`;vu)F@p!?Ny7{WTp zy5ZWKZlrUdV)(wfCi6&ikMKpjBHJQJ%jr~jWh)57?rn5R*EbY&2xp@cKHh#%ducyG zkc&#Crg)GrpM8lLqtN?3xL&Uz{Xb$b`z||aYbM?yFIL@`_VA?(U5mGd%OsRuCIvsC z3^!pJ&cSk$Sb$LuXX!#OQc#B|8QyrKM1~TZKL@c<;+)$r#Q0&hh#8`CA&qu8%k#;e zs;G$vl`+|itv40e~T&K2f<+Dd^JO2Fem@~PvnVSw`tka|U zQuvxJ7upjP>d&lf@txdviDpa?w--N6+5jC_40T&G9yOZ2uWG{7wre40qrF)s= zU>cD!$IX0dbnBh_1ZZ?AGMAxim44lJ&v@k7@w?1FBD5k{_=iEB+bVykAht74Xz;rj zp?(?a8zZYqjFGs-p{MAsu_vkpF1&&GCsSNuj9IthocwOr8b zp5>IwxXw1lGbjzyLLTAw9f0UrUl#E@oqE7`SOJ&bh)-B3wh07CU*<*Xq4!BQcw-42 z#y7zrNcYh#ILN+V&21n;k83a2f9~{-L3Rpr>?jx?n{Nm_#nKUs%$Ic>g7UNmI&+eG zCTA0jQp_jC!O2g+7$JQ!|0C;3IbSNuZLxaUrGOI*54P-UZ&TtoGt-APLRxc!C zo=T|`ZS(g`K+R2tUHCNs*31cBx|DY80yVIioZXb$b(^oE+>*y_X`hCNfo_qkl8}9= zCsXaMohO#7v7tWZ;e5&$m6zeJtAJ~-%XfW}1#&;@tSZJ$qzhVk5RZ{~s`=Sp6=hj5>j_vW#YD4KPTiH@SJPN$249898t#mE z7&y%1w8#y}-ft|{?a0-Efl#a`qPum)R+VF}b5{nsx~wsqKRMV(9oujI|`mGT3oqo<=*k8dn7Y|_FE(I;P_DBjk6r%%y_v8U^twlCQ>}07QY<*=L z-AWqZXKCzy6*q~CpvSB;qW+dp(fbkc;~bh-E><2Lni&DodPZS1w?FhVZiy>aqfFE- z<(BPG^9I_9sbrS0C90+Oiex9}SaA-SD))blCCB@ER`1#S?}1D6yH#uXqpu^Kh|rQ0 zf9hVKgXr7J!BouKFasz+ASOh81EvxY3j1BXf*8MwLJ{CVU1GxKBVAeI9v#!|9U4kg zMg{hkiWX>&6mIE9y~hlO=*Nh~%FgRbEI~BbhHG-2+&Zei+6H~HhoJGuf=F9eAU3Gr z+YvcW^ThVX1=U#@h9>wV@Vogb1E*%w7=w~)O87Ezs%4onRmw0|O< z8>Z74@cl@wuxzAG2cRR2UW1RLk?zCA1@xr`t-T%fVVF0eW)=LH!t7BDonG-KfKXp! z#tc%^yRQ+534DCR=;V5{kpu0Hz!j6kBrPNGAmL531xRvaq##LDXBn{FFWSb780LFs z>66<_Ee2l8P0u+&wFtNk#~eHGLF{2%)NI&$7YdSotwX&K>b7v19BJJGI1fMs>kiCK z+K&>{62ge&USo|Fr#niti^A>AXLp$rHx4(-c`CaTWXCshxYO!Xw{k-(`kF-rY(zGF zJ#h_Rp55O-jb`Ru_{NV^kPCq7v*ANoAu%E0W{8jdDk!y6iHSwABszO1QkSK^dd=g_ zJe+@uMfX%|{4=x>ToST>e{eH3FfiTQ`zXrvzH7vbhdsf?y8oQBk!p*-$&M)#Gne`v z>VX8a=qTBmH?uMAwh~YDGH*u*9-(ItNppNZ57D$}{mbVgic_n8vIvFQXEf zj9s_y7UD4#T_YG=2G=AvtVYq&oj)D>zt;%PC7Ae!-CrSv?bt$7QhgtrAT2KJTI@Zt z>O@fG<}6f8Cit6DX(-kdx=QXy2;|c;%A_PTOyjdPNv=`V0uKJBva_buxctX4O0|D&lAI8HRR^pkXN3)vooJr$=S%ego>~P9!h|=yW#Ws>MU%zAvK!Y1X554rNq8}Sa`j&xB2$KvE=jy>Ea)8Xfd81de zj^o3n;Cam1(RUfWang3Fyc65zqI}YQux>RID?34p+QFo_J`LOVrBe;OuPVXxnp5B4 zMIHnJ$2pCSjH6)$*e0|-BXQtuFP|GDaCd-0ZoKh2HlFlB#9ZGF=l7dOdrs2CSVhq1 z)|_tL+P4i%jJ8`U_UwsmFWJ%FAu+axVrF%VrjnOmJbwwQwo8k<>r4D>aG;8joN1 zHy@K#UELRNwieRVt5OFnSb=@do?DLRISD#GbjQcJ!ejAHh9CIXLjku@Brwsvk`wIj zRg@V3DWlEtsG;0LDKzNKb+n==rkmoOsu!9-wHyue|+BME#4IDY&5zz>Z zFgdZpiw~zKBDFDlZ6vbFk`3r&{BRC0?*AMfHmyCYrLEeX;PjkGCrErDeehM%pp6sk z_-++Dhpwq>%o5+2$)h{UcwN*DD=cBm;PWTJt631V>j4B({hGQF!C}I6Q-fI>rAzzb z4%VlqqibgmC1iqTA9I)7O^2B^zNAt*;R`h7+t;s1|DXws!r^%a2p5iD`hKF>y> z`Fqd)aCrX1gI5M{L798U@#s#B03PABpO4x&)HFV<48b0xu<=93C6q2J5ItZN(ae4KZ<~}W7~a_X6GSS7Vn>K%6P+#cY*UFFI$LJJ>^m`iIEB%@T>Ch#@+K^a5$}$d-*7GG~uk! zn1kv!gmkRe?{eJWRus%q&p!m`f zn>qcb2S57;%YaetSI@%SraPhqZ(+Eg2M`gr_Ee3>p>JvcM6) z5S5mY?Nn0$DI_|KLs~-pUrE$>B_U%sPEt)#cKvDoDEtexJ5`Fk4Ftbox>V8L7B=;s z^PI>P2VY{}QaHV`s0>0f-5q#YjN>wb#G)2|)c{#XD_!qWdLF4ax(NUySUxQrq3rfE^7kniV#Fbf1lt%We#kWzu89RlN0StvyHHsQO! z547?UtTf7$O6IKOM{mj+U|uSg@E$?t*&t<4cARyBWqS_dwKyG&V?$^z=3`KgDMO#1 zh{icRuUdQ6xX9Q8PEV=B*-FJ4g;tr)YY3R`l`Q-0yq!8;s|` z$)5TX@1y68foi&?7w+<$;`&(fh||%1*-K57cc}V0LzvP}i;eoQX!H|5sDoaE=Vhhh zw@-7?sKHxbEI>k&G=w_%>_Y220r&@fM)@O^Nj{TnzIdNFPqgmH4zmfU#{h?UbA zA5+X)x}t8ZBY`G@9_OyK8>|K_qa+hon~-Fm3HGCvG;$CTZI<7q{>6Ml&v%i-lAO1) zNR2KZL_V?Y<2jAVXwiH{G!HZj?W=cfVbA+AjXP$MK{cYEkIM16Qolk;r*oc+{dR@5 zCS>QexKz$g;!M^oEd;e400>R!cWubSeyq4|`D8lqWUH^l4#%Qm?!3lL<_AFBY#)rV zif1_+mlX|mX4%h=z=^jkN0}!=mM3zupP>t&`yFN_2;Wo@T?uYua7r)+K7~25>*q3z6H%1y3iknEmVQ+p z!IM1)1|fGIFL;eyc;*N~{(KPj>VULt7@7#Jl@g0vjvc@->~QZv`G9w{+fxZ5vcFIu z#{?p5tv1PEhs=NXz`yV0&Sp0R^fqSV{m#*+=Se~A?io$>jPCn{`#S)j*21VQGC7SB z7@O?8qApF|WrpJjMXZW*+f7)Ys(7xBI3tk&uTxRJU>rK%#YOLiCQn+W948t$9G~%lKBFjv zM79ZfXR|O8vd?y&tgQzidYUPKfkb`izYEVc%V`NwXDdK&r}<_JA`5-HdJqvxjrdrL z$*YRhd*Zju!vFV;U0|MwWqZIjvs^dh_>x`g`5U@e*8@F>6EL)f?z9#N0E7M;4<4Yg zkkq{F8phaCWaD#r=&C77?%YqC7;?RZn&7F>V7o9@?WMz8y^py6W@R4w6+6_vfdsRd zg4GcVy?HT$zK0W-Omi(K8ODo!Ah$y%oeCPr>7*4H$`G*5Q}+ zNzjMAymbBNKEiNRh3IE=1Lj~Pogh<`3rM=;WSA#<=fwRj{4L>EsFj-ws-jv>NcG03 zLcci{D)=U1S{|1_7sI&`+Kqc@z&>a1GBa~MX}#srDHDY8cbRS^{d5$^X7RF3)B5adC;;(zO>u_%_IX-6= zjwc3X&~{jSC!4@}3*{qIxEyRNPvW=sp!skbHxm4WT2Bz6@;SI5AIGDEP9`P!w9f`i_@^fQ{lGW!eB2wLt>j!n zX`FNB2S&XD`_XD4qGFm@9I142%MUlXK*ZCC;HQ&ACBq@nl zaQ2Dv;bBhHv+@dkoLsDD-);UT<(Dp_!~EhyQfr9HbGD0*DggbecFzouI=Dz&OkWo) zhP^DPT0Abj5WA8h-+b`hLF|pApn)IXuHM_VvCBW;h(OR~tQI_JjA%$*Q%$oFdvnpF z6A9R2SLmSGs_Qq*yn<~e^%QA0edbp+PhoUo`i8f!Fq0&jG;AvV;@OrCy?ej06|}i& zIV{Y#W6Yx4{pN+N!|S{-42;v%))&y02c3S?HqZb~^cai{=1JU--f&$^S!uMs)fQzU zH1MRdeqGBZ-|Xq_3S~Vf$T;;Zjellq^(*a~SZm`${KKitKelH7vmFS`UxYoPB%@F_ z^?zRc{ZCrASD^ap%)_d=2h^D=zQS|koIElOM)FvZ(Ox}3kfn)Fiw;ooSm;HRL8ns# z+5AB|T)RzjI?9^wZ()p8na46>RMPr4xnrM0nt7js@7_q|tZ7|fnsC(e*u7_=7`5=+x64!}d11IhVvMJL`O`odt_5yVVv&CXR zhpJuBb5>gjy!Gyi!Z{&_ho^H-zvfYSmt4wC)lO@j3p!Kw*e$9s2)^&tsSyMWM#Q{) z6G)J)>G*pZ_bXPtR4+aEA`HfM>daOJF%Og3MTOg8juq^O6h*7I!7H*+ z&HF&sf$7vZ6r2(r{d-CG8#D6z-6cdceDC*mZLN;~;Nzxrsx$E3@U0W!byx{|%?;M{Mr;$LVhhF+dcO=$)VrZ*nGOmm{puZ=g z6DXbb%_$THL3;~V&084Phxa!DWOlSg`%|R5I}ZYV zX?*T>ryiBpK2An!K>T>uqf*dTj(d}dcMFjyBzN<>r_odGbSiil{z#6=I1eKEFPJSO zC#ppDNEXx4DkB@maoAS9VU|#}CICmMg$ZHQmmwpJ-AKZOA*NIA`Df4{|#n{A0 zhIIYiNMnPmF2|ydUrbrA7qUm5bB{3<8>n1R2EKz79kyK)ev^;Sa=beW3^@Q=46&EtZV@LPx6G5lZ-L9Ykd;R-r1&r^m z57vxfZ!sa*y+vS)ST>O1J*n@)cL_NW@JGw73!?pK@E)5t!Be6?0|Q4%J0M#_vp}Wa z6Qeohyo^#xQMifgOB3in_tSD>M>01&uH7F8CBYe8)<;uN@(c6KUr; zK>BO;UNcWu=&uCF{j7>Iai11!q#|US0`j7zo5|K&fVSA})@w z+GR>-6k+Ggmz;>-fO6c~k@z)v9rr2vQ zbi&jD6YuK5_44%qe|!CnnRY(R_xCL4P(1fezxArAhQ*^EUooL|y7Iki08TJJXu{8) z(-1r=H!{8Bc)UdOA8vKB5@<1oUS#}XlfcbmIEm&=oQo3kYZjLD^H+nmShLNb{V7d) z(LD?`%sp)3pwqf3>mh}F438*_D4VgV2rG`>ktg31nJQuRT$ z4Elk5P(v<~n>wFOkp$3BVzm|a`HO=Q&~oPNpbl={d@7lrrvec27?cg?fXaGxEhNMQ zwVyz?g!GdM!K!4#K$-533wK~D1O30f{rJ`xf=0e!?=nh_WR_7hVZ1Z!L5cTkm-=|;>r-Y@#Fmgf%~*==K$zD zM{cL>cgd+?Wpu%TD7!pGwn4)yx}C#*yCI|m%K9A7G#^=pP-n(27tv_S!Q7NP%&jQ~ zM6QU!86l2YAoAnm{rzQnD*c*;l#e__TGCy;HAD?fpG7#FHOT5cbbk~r^yeFfGP;tE z>qN*IAeudS=piQ#M6pTzKyebYa;SB0`^PfGNf}@I$vkwv-U<4)uYJn(o!lgX-%~Cq zQh$5awUNlVB;@O}XCxL~i=gR`uUMjD!Yt7tO^|HItF~B@Qa@*V{L%0ai-j=bzpOeQ zYKu-GcgCXP9v{>DM_RPz!3h-ava8y2QUt^YCn6BxjYO8Q4?KX>xO89$E!}0-7 zjI-~>Dt`TD<2`C~;b;F1M~BQ{fyj*GM|3>RAClUR2(!jBZ~O_(`k%&^^clu!$9lrD z(&M}0-yaag`T7Kth@ZT@s zKir(--tzBW6G%mBLNrq$%^zyQfA{9!tBe2ox;7~wg972XB?5L(Pd1_VqW=wd2dJvR>jfM0E@-904lYiVEw7=roY15{o~zZF~bwPV-_8y zoAi&bBezIQ9s|=}&1SV2XFle>;=la-|9Q@i|C6)Mg{*OtT$Yjx|F_@z`;+-URZ5*$ zm1m{a)O7zp-+}+SXI0iD@?Ze1K}2zWTAj(yH2_)AvJgWsuMh)t$~Y&I_~{5J2zo#X z&W~E~{#@Q0f~fAXj%WPoB@+4P;QtQXfF$LeW+ z8~5kofP+#@y;S}j1-Gep0T-X_Avh_?U?SHQ&T zW3JpcM{wl&?>iFrH##+-pg9l0o${nC>H?%NhC#lMd7=^s#?`XAu411+LbLckeAIus zSyh`sagPtkAyn(r_<5&8hCre{i{b1N1zdub*t-8qwcpshh1_T5vgRsm_k=D zNO2IbYunur_D(h=->{hK7YCdVF&mCSATN`0;G4Zr-KfhF5O2x?p|&-AO*hkcfN=;` zi6XWK#1$bF#MR~xBXq*_YhWHX(IE_OcSXtz3jmasjM=r_5Hg-k5%INxIkw|dGfHI| zkhgPCYn%nFNl6}9M)YRk+do{wf8MnO*02F|z52K??w{_)-~TFh2?NC(>EDkut;hvMdHH=vb9 zuL0yY;T+00?8$;R>VlFb@C}aiYv_z*xuRf^{Vi7Pja|-nYSE!_8V%FkN%E6Q7(J8y z(3}k2PFBfa9#iNCl0~Uk+x@Z*HpE;##D77!j))j*p;%JvEmW&?be@+@ML=*$7hTB#cqjtWvOZaJ2J_p6hgo0%u;yJy zWXol8wGK0}9-7;%STZS{c8JsTZ#F?82gD}GLU{ESgTk};FxG*zZY08#6vljO#}=;r)9s5s+|xay>FD}v_pzEg*P5i*(vO0x<5 zGI(B%=Lql(JY>?{+dl7nz|u0S%i@@oOFOXzbJGnLAwn7t#tIm88v|~^pmf;=CdfDL znmt1#VSQVQG>D;X1w=yK{X>p_ZEJXg;e<=NZ+W|E=F9BgvjF~d#%W^$cplz{C>TZI zx0q`KvL*}qqc1GVKrq#Q`*4GNJv5|Ja`Mw$D!WVUDHwp1nIw8k6`j6`^y>57jCfh* zKG;Wy&HAjbpGYF+<#~NqO+$qw<@|oz&92T0Bt@I|wXY=-Kp7ruyNYDCSp{U4{StYr zjhb-)_^AeDHdlLY47PBF^&7CJ>JA`KE+V|GeNPibnk**X8+vUEcm$Mn1hO5!jWO)+ z`9Hor_RRHwxjIDSJ-bk|975=lQFxY;kN2vbV>u9nZVEb~xA;@%*r_HJyA`06&o#c_ z%Y-Ygd&Qg(k1^dwfsu3C z@ome%GLxNpOWVdh`AOOxu>m9ak~b-9oPs`LpIbTVEhV&0tt;@Gko)*)O5xRyM^B%B zA_#Q79_n#X9QDqB>_YhqJzH$?2LC2O_nQ}_(2t+rys7_j>eIV+yF;$6oJ~Hv<=j^J zNXMS(R}1+)B|YWcyG!}Ot$SsMFyo9fluoI@h()Xs#@swv;rmeo;}LXzTrnf`X>BT? z77yc&-AFGL`Jnl|#B&21Uf)-=yvGZ+?nR(NG5vHvN5eytZ z+f}`iP||b-$i}l7N>>~*%QqF_!i6gJq$#l30YfETt>$(f$uCBBNfthO;<@g0LnKL{ zBuUEjPTv8{@rp8rQO6MgM|5YX+`Bja?goQeg*~TE6RC7>Z!phKy3gT8`Yn(0trisx zf5f@r@zG`eIBPi{%%Jem)D9U2zDX;cc#|Ud zhtJ$Py-A>8CKyI@VHh1^*PiQ!gwLBAD?Mi$s`xMEsOIX3XC?7G*lWL=%x034aBUfs z2BYrurgNnv9$P(INCdHEWE9wG&LebomV&buC8=7mxpo#`IUbu%Qx+0WItWOZL@rEI zC^F`8S7wuX-}1~Ip~#x_yNs24(g@01o~wj)D9!z(Xp~(m11w!O=fzVJ|L)h;zzWYs zMy>uf#_IQxME^b84`!|X?Mp%l;D+GvS_z#vZb*^yAgw6?5m?TxXURE>{57~Ap(WKJ zZry01H~U-+DA@OSAFWL_Gnx#S$(C8KzB9ZF;7Gby=pCjfJWhxE?~GT6Z`-5Wj4Y-l z-OlFb!i+~v*iG%)BR}?}E9oLPcu-o(Ls_jOQCyU}+s|u@$Ds4J;b2krz#xE;4Cg%! z$3cWa!(CRB38enV$<}Coc`nT_*wT{S!6kd>R~bhU2+!?0y#dK01I=6`3$I%@<(yOx z1#mU??DV?OGFEk$b&E`27kOn@1Xsk}8{FOaUN)yH#llyzen85uCz(|Of{0CcC+6}i z%`q_bS&mJaPc|sTt(Xe8B-m{&F|DUMboqq0nFwF%J9FGM`q$UKMVzP}Y1+x=`ahTW zpKrxaL1!T9Ps=5&yx?A+=V}V)YIQu=kdyZK5hvtT(vm-CA11Q_r&f_~-s+TejI{IM zqXecjpwe{*sc@g|O6hjXCrH$zo5Q&VzzO%!;0b(8@dsZtzj|T~T)O5f!f*pw^>oGZZnnZF2DQP8uUzOrd8m6s^6t2S+}9Ior!(f za9zkV|>jsel*mdYTtUDN1G>oS*KB03byQMt@*#gW-Z39nYJxB=-M zxfrWvndN&J#iiF)6&crZZ$C}MVi=yt{5&41dB@rI1g~&mV&H9`u-_W^t9wk&(>J3g zoYi+2kH7p2r5G^DQqpMW*W@BxWp6tw5TcxQ`RIm6at_1Q?Ddm+clcu%&vacXB1uod zaQ>l%DECUigb^pF+qVZ#Wys;jbW?xnz2tk#+p7~3OT#c*_uXhFU$Ck5bLVfSD_p6i z(eBUEU`A--HS;*BcXtOJIC-tAU9b|DzN5c-k$7-v_x*wxTjVMC3VG%XW&pl{bUV=Q zU2j(vo*Gb`4QF^%9q_0MSzVCXcTb=yA5*GQjuW z?J*+94M5lFFkq|N0LufJQHr(Pq3Ew2KrP-*Mx3qE6Dv`ljaILAdHPzt`_%#zVqP5L zSrU=n4#^6`xJPtF4v;K-TI-`i+txm4AD`;n*7~bAN)x7!`Z(9n9X}ndw-W1;sTlRo z?2(-F?14Hxo-jVrj3iy-!va=_$`dOKO+e5KOX$Nf9hX1qhk(X0)cC>utT}>8a+C8*FTA=iFez6=2ZIJ zet19vQw*Q-){n=Op*4{eoyM;kKG9l%Ev(A17SSWt79@KR7q)ALk zb9`x8pG(r~=&rC02BPZa&dAAFJJ`B-X8RJBw|GS6^YvZ({Msvuo@Q8AZd7hs6^){u zA1uy=9pd)3^b^z-Cx_W>T=U)5pJ9d>I3>l%Bx^_c5;fpz$$#wJm5nKoPLi-{piK(F zD7#E+IU!T@y2ceJV=c#JhLA=g54g#mbVHaU8!r1`EnBOen%9+LJ;_d0#oYmn!7a?f zWpkw&zQx1MCJO_FnQCI&=o5~%JX#(!0inz)@yjpL*0x5n(K4xu74|zu6LmfrS~--_ z3s5fzAp@DKR~!#^u^JJsy6Id~^_x#vSSzK#xbp^d>PHCv;d=h_6Y;GGuD|y{Hq9Tl zsCo=UU}(C5aC2D^LwF=A79YA&<(RpfVABxSs5jM*baBU}gCb|3S^yTuv^=P=ZnqYFz+Zr)_(`SP1A=T00Y~2{dn7EYseh${Gc-~#5LLyqJFG%u^YrQ0QXK-03 zYtL){=BE2;dkhP6eq(B-7PFerkXe`zar#!M9TerMPNv2z!;WJD#x2w7jIFRt;Cz;b zCEwirQS9nBb_$J~u{>DJRz@o?3n|ILs<;uI8UrI8Pzm7G-BOg)U3u}glJ8yGD^I4Q zrXLQeO!XC@55ao_MjTRBFpg@|m&J~=JRiH2b zY1_VfXQ122J~>rcd*ystZUrVv-(V}Z<@{b`*U$rUCORo7D#mMQPjhK90o2=}jOb!` ziZ=^t`s>%Nz=uNk3c-612+s2pmfj+-@QbtKI^&5NozMvzyBtTlYB`S;!DE2NQ+>_T zwUlX=wHvbHFOJfO#_rBg+z4BqMB#O)?5Rnp5o74aFH1YlK=sQbh`buzb3Kdw3h0i^Sd~ z&7t30zw@4<54tY`o0X=YWqwt&zc~Iz(Bu2^FBJ4`&D>nRIio#F)io#ECdv!S#I|*n zcunahB~7McD5)*6X9Rd+;ONb}LH*Jqc}7Y}yKX1*mp2>{>#iFJ&H*_o6BrEo#bKr? z;h_deE^GFYp28Bpvkxv!uh5ic&t}IpW>kHkKObYj=;`MpgIT)VGP6DF;OiXi1eKw3 zV)jc@+)K{)gSBA|jk?HCcN+^kJOQ$h?rD|%|AW(4e{TQvua+i&d4{%xbF6*<%axPg#wDBJr{GX>IvLYzX;;pF*;Gj;CctDzA zIouu1qL^^5)C-q^xBrJ6l0hM;RB*gisa|2xK@JQV-H9l!UGMhp5TE9sh*rsSJ%Sr( zi0K^{*y@dvErG^H26lcVqBjLvu9` zcJCg3veLgc$|mBB;|{+QKh!F3tX?sGJ9YhKIoAo(oiBExVMwOs zWq5Fo_+9C4gSRAn*_;Xv{xIFif-BDsIC%s*=Hvu}k=qBF1s%cfK-y~LX^2p0Y9DMKL83I5|CLhpjqyX&mY(@7Ke%aT%I$-4pUc+(NQuzwi% zm*N?UCX(r;Yr*rDy0D;8o^_$TM$p4Jm2IBhICs25c;n>tc^8=*C=WzwDw9_=d06$; zW5R9^=bFw$n=6GpJY=H+vQYwq4yfUYz}ZvtN^2bvawiRbP(SU!D-xD8)WeWV$Rwn7 zmU!hbjtJWGGyd}s{_P(9-&^yY>(X+Y(9fDI)PK2G?art#ua4V;^U|U5rU!)IxFd)U zc=gEiBN!&Abu0i}qJ!g4Mg>PXh^|R>AzjE7BZz~8ra;~Qvt@A+eIBjF4LbCcYO;P- zRWOErw9Tq@VjK4)i(tT}7bo24(eLjBV*c3or^w3ZJ;Rq~n6xg`F~Tv_OAC~%>-Tft zd>*^-4Nf!pcVClZAH%whBXn z@|HT&B+eto1h_kWW6mV@Cc!)L@7{)QBCrbH*W~f#RWX%6TkSkvK4`13(7FkpH&7N{ z95*SEH9FiPsaILzQcw@J;D`$~pk_CJpCzuFysf0|mYa+jVNlmbQ!y;y1;9GjDMcu@ zm1SAGggpB=_`ZF8ArkULSW%(%>1#%dB-S6HqkBLkb`WXH%!6ycgq9hl4jC7_JEo@a zuHO>I8PZf~N;v$=)i8marEu;|tG=UNkj0)Leb*mW_$Sl zp4j)J{C+g(FuwbmKHqXVXxM7ce|e_=^JS=b+;_r*V|94CZ=3RYyd(_N zewmS~T#%Jd`5r8Jc>Npzg;d_mn*=n`0jG%#2tA8Kj~SdYH=2J^Rhoq%APQ}Sk)SMH{>2zdt31wK(bDkhzEw)+-xwuunhF`)ibN4OHFN=eXMcRcUp%1_x8`Qa z+3C&A%c*Bwhh9gg^Zb|V_pf97|59@my>K`b?ffrqhW}B2Aci_2I`2AAFI@S__KpyE zWV}X3Kio{I@-s-~stMFcX#A1oi)fF9CoJxcY@E1+{p#Y-t&QN{~)M>f9aYEYH^ijQ}R#4w4WloyD4%kNL!40-@UF(nzsG>bFK7MZub4fon^UAC)5?IL$NHtL_!LB8x}2cTq!i`!8UJ z>!ZerD~mC8k!Pit%l%u>N4WQ#de53+*cjs4Z+`fb7f8{Y(0AcHlZ|k1aZ&16dH_W3 z8W#vG*MNJWKj2UF3ZkG(tgk2|*~sSd_bAuGoyq~RWot{G9zN(FjPy=tolBlKfv&n) zlVDQ~0~gCM<6Bq1;z6wMI5pf5(w?}_y(8P|E&&O%MD7J^3C{7*ok4%f-pBBZ%SQKL zU=`uFV%6Zs6M$)4B1g#WmG_CeS_l+iiIn!?NrbHole#~-bAVD@8NTw+)U5;#X%V59 z*)x!BaLZykN-u0^$6=M%7*`P^xEW72mMc#rt@#x#AX=qn46S(ftMytB~;9b4PCu?@J~4; zQU=UYMs3pASS{*s4AF@`Lh5^~Qlk_09gZrIu|J{r_mWX)Qbb1H4<^4qKdicAi>7x3 zCgLO4a{X(`gV-wu`^Q`!+=5yw8G_`E72++cA2 zF|Ig|o+GOL?#ij+MEl2>?_!%th9#a^Y|t# zj3!aj7a#*OcbYxL*b$Y)bi{{I^hr`)(BTp_!zN_YgilU3obufTfLM~P&so_k&)#(N z_NtfiKTAtH|GMXk8Sb<9r3MB5^WXk)6g=2=MQ~Ex?pHV}Pwue5Zu>W{#Vxj2;w83y zx)P)-95)k|ak2{xw@&Eoq`Sii*(GzB?G3`^!}4e3*MhphiN)16){#T&0$kn>lZ!n) zJXvYXnlgG@hBJ69qCar)h}kmMe(qH6{|@7I7ENjfV^c4{*ErL!OIV;5smCbLc-)_m zVSI=pweWfYcUrkP8Gv~{l&D?MVVHL#Ve$Fxjpt}`iDPg3JpDOQJMMq1o=V;6xV*Gb zO){65gQ=*#BET85juX1+hE#IlceFjcC71g2<~~5cq!oxv-tbBow>#?8*ant}Xhe>1 zR=^_dxn|1+(!|FoTXRD1Y^572342gESS?pIQ(ehV-UeY9ih*(qnCU3$TiZS=e}C&e zMiA<@odH0B(@Cpd$^SfA0nU8gxO|ImwXw|DTFz$=d(t+|e=HwspQ=Hx+qphCqtMy; zCtg&_m6v|m8~i3keGE`mz79{hW!W1o?{kC@37#24U`7M7041MhAJzd(re>}G%>tzI z7HF)PThvjlG%ZWOqyb)3M>k>$2%vkW!vtqQdTj_rsxIgwTN2cc!+biW+(PHtL3Xm6 zk5g}(@qQ$ZYC~)ryz&X1%HqMY-yifzb!T3`D>^^U0K`!MGXVpj#n@tVsYQsjga4G} z1IC(kEeBA2q(iqsj>!5bK9a6jm*P}Z9kf*wlPPdBvoWH2t712#yZ2YkV1gi;fa-{q zl{ZPT&rq>w8TWA}DcxOP7 z(9fF@`S#5hm-liQEB<~OXSSi21fQxreeva|l%}sl|H+g4`vg|b&punrjiB+Qj63Jkphbt?UCCL_0VR3}psk*78Fm-6;?KE)k16^|6xy_4XzfptN}cW0Gj?NP2?Alo}K%H z1k8a!yE<3)&|NEYu41ju%8?Wd`-V};(_b*FafFK93OfLF`^^YTg7V(Eby0F{pn0_e zFDM~S#|3`_NPVZ~7|bS6`FwbD+U`4sFGD};Kz|wiSSh>+g~KUPUPu( zH1m<~20lI-e)Ic%^!L5`R)cnsHd?>ZTjD#K`{G=iulQI*>mLcH-z_Btpvn)_nuudl zUt76KZvW?ta`w0jsCtSnTF-^>4onknp8K~KZeQ^_7`=q!b)-)G{$3#fcg%`2g@_}b zkWkm%HiL=Xa33k zLu&+%SNZ(gr(U&}{I_p>0IHOjkr!kr#qbY=no*D}s{&e;4 z(sciW_oMk_%^G4jZT_%;KmCtRNwFd9^!~YvwCVDetb$Q2s zyZG%y)X04Cir=j9+20?fr*)irCVu&7eg1|^ZheN!?Cf`US@`0vHG0b3tUu2E{PNwL zg~%5X%Nc^@UG?ckV<80l@i}U(=hm`;IA&c~Z>6KV^DLVc8 z-8F02`0m6siCJc)q1dI8C+pe}>9k7->u<#lEzitJkC}ay?mIm5u6WJP<+`e^0V)4; zU;pg|SOu^A^XuA`;c77Bg$0tH{QjB8qBuvn*stt;{6p=DKYP4H!_SGM!eFNk6En9>NGp9m0sb=>HcOIdxC zT=5SXr|xzj0*00ghQzR``mj6K0r2;xxw=muAp@%NOV|l#FYU5J{AV08X+)7hsZ&(H ztOn2s+9DHa2nxAuJOKk^M;Hm2y`r7i{$gUS^R%xhHuWXY52mw=6Xg?YZ6cRMTa&L< zPYbpX23zt5ts4dgn}t#)egIBz07}(vYyJK0aLngHq}aE7=qDfv-2RM!zvy2}%RV_& zHk~;2x!%>%>G!t*xd)&2`mfEg-fO+u4S}-roH0vkbY9?+nO}?8U1fOK0=g;qmQ~i%L)rJV*_S*u3s~1n- zX!(TjCdGrVj2!x_0{hjko`4lXm9tO3LYIJ^>ShtCNU%?=d=;6fvaHc*|$S}hD*`)K0ovoM9k>E?MM5FfS zh^<4CCe3;v+xY#j!CJl&&D^)!I$;*k&zZ0nlp6M3Sr$Qv*qZ7DLODcW_s&q*{sRKS zK#q|OG90<~G>!Dp(jIvJDP~qDukJeT&|TPM2s*?+a;)l3v%1L?x|mD{Oy;j6;YBBZ zzE!##&@ci+t*xy|HvM3L$VT#Z$wbPM_@M~0xHO0C;rKPL=5Jrb0DITL`HQ!#hvdsc7(ElEH zxg>sma?X_N)$g~BV_mk=TQh2P+O05RwM2)@1K(RJ;Y(%SfsUrLYz437#pbKxnF$j4 z@RX&2M(r~Xk8!T;GcIw_$Eg+L(MJP|c;5e#_L+4ke?6*)a`zo&LHyp~l{5Ves8H#! zWP>c1saOC^5cKr5V^Djp6gr>cpV>1H1Miq6^KTjGvcbqy&?JTV6++Hkfay&S>#U*2&;$u*g^!hbmcD4g0uZTGJErUelH5xPnpx#Sv`~7(Q z&jZ9vaXa+EF}E9ykE{Z+W;j0+7SlZq3eptaB*L$q>Fse^d>vKrk9Ma z^~Jo!qzJ*-h|I8s1|P3{HE>N11;!nxnwNfl3Bs`52`lQ(^LiZrkOgV>n;7NYusK|48?*bAj*8>MylClQPs+k>d3k!lMXv3_H~i`b$$ zt^W``#H?<pgUx-6xnFNZZWl} zbXN>dxL`EBGu=k%RKi5r4QNX9Z%|V$-EUd|)6Of2n^QWqh=?4glf2>7rG>~{Qseni z2c{m2v?rx1_*?8b<>B27W zJAd&TI}%sJgxbvrmR*uYa-}(w03d|3lg)jXM%gaofb5RG+X_ep+i%p(7Cd zNXnFi&ji zAY9@<))7V2UYqr6grPsB-W^2tBy4w4mA=-3_Fcv(qU^9pKi~2|ei7%dD~!{7-O>iN zbkDsc-t+2-_Su+q_GB27vFy4@Rp|y;7+jUYK;pCd@Y>qZ@Zw7kci?h{dRNFr;1(H& zAi69N61NHJVA86tg5ZW^jhm#m<4N_`w2%%&LhS7iJnI?Mq~?IG3|q zDemL+Xg>6$SbIu+#zQ*TNTk?L?t>$pzi|5XXsFnitJ+~E$-7GS370Hp`y$E_QDmoIaA;DAeT_h zn*%Es?>Z!}#m*?p{Et*gt1mCJ0K>I+7rE*T zy~xAUjH(kFd*BQaG6`+T4yf(dcJYDV-YJxv@MrsfDE;2>S~n$h{k)c&FiEjyB3LOCwk6tt_)? z3apZ9npJJ6xDEYKx;M-ymL^M?*!|WhtC>BcJMswAe81L22nH$s}QLjG#3i zLxd+jzNZJIPsUX}LE~%HV?Ed@ZViBxk6XWO+46uDMENYHo6<-M2xA%ZJmx0K#JfLS zz`4$A3milv(H(Rb@=3SLDpA?|GEQUBcfnry&{N}mgl4bg zihOg9a)WYUh(#y{KR4FV%(J&mK5gQi-)W`cNVU=4i=BFdh@+voJf^B)a5CSswyB|E zQdVAZMop_7FZic4(M{QgKyIBfZQCU{QVyv?G`G%IW70$Kr=)H_S(QWhPh^-Wf^?i$ zeS=qPIA0(~)^S%>Ilh1PbM4p47q6&aHW4?te;0g3F8^tnZBvRloq91&B}(H&ne3_e z*t-zs{QT_hAzSG^3=Aa&7xt8ODUDehf_2jYawZT3lL3Bq%0NcOc75`|-0BE8h)nA+TQ4PU2|> zcPTw`FGFZDrdYz9Mj7yPQ|c!Mb`nhI-^Njv#Ttbq2rsFU)xCl6^SXeluC5-74^ha5 zB|XY%T;)x3rcpUI^^v2@69n>>AkS)x>RQ$^t3C_kRA@oMY!fl+7!M!wviwj8|Ebeg z2w3kzE=}?F$mnldx6V7&B3<5BA2DwQ`GdEWjc8J6$}&Kzrs@Mdq~@RCTcd@jjVxaa zVnTnJh;F$&Hr)_0$=+3pkE)AUXeyl?L6lwKUyySLCFpLc6 z$~fjDaTRBOq|{7KhcmLJXTQtR&zdfPn(F&AKtlZSTncj50^GEM3Y-U!^P|9v@KE&2 z%N}LeMmk8D9=%2ubmSJf4zFj3;nHjom8DNcMR?Ui%Bx(Jw$#S369j2&jcy>(*G>qv z3G{Guy$`-r{lI;^?m89vm7gEu0Pfwz69K>8!^`nTPxTL)CGF;dxV3x`w|2p)VUJ!; z<&nh&X`mvhC|MKHw8Zn#oUfpZd?a(sytFE)tg7F#Tp}lfu9bfNW}5b-X2POUTT+{) z%3OX`652teQ+Z0*qxU)@fi8?>x zqwhaI@{|%IvqWcCGphW%Cs~uX3Tv}5dl@Ivzir)xtWR}%jk$)zq1cV@@bGc>`6xfB z$dAbBL56m#;TeocI>}C@J^iqMPBXR7C(budox@~B%?lEqzU^g3hc7Gdm|nY`ekx7) z>6OH3<8QtMNYbV_QP7;E_}kVuz}8&30Cvr~O>&xGFgh!%8`qan#s!A2fYDw>Z{`39 zCfS|=eupm7K2*;+o$OcSf_89m9@`}7p8j0D93*leSo?QR`2FGjsBYA*ndeJGs z_4^mM4U2muz=C+qw@WLS^T}K(kT-)Jk!p zx%9gvbfDPepsL^>yc$SP#CR~)WNnr^)i(!}d9W_$W~#5zJ&6qa|^w*KJV`hZ|xhC_Iac=e#3{y^t4qPsvVEBZH-2 zr8lWJMG{9$>G*wmG$JxQX_MCLx-F`es@*o?+3T!$CR{JDiGbLZ3|0&P;!XMG`8Wcx z^M3A_xy5tm>;s!424ZI)Bq_=ebfOh}DW#x;U>3h=Y5;w@0dtRjf(q~4nD(R(wCT$y zH<`{H++Y($c*P(8gWMk;em2t%bNH0Ox5AgTJf@KGb{hn8-8mN;-op$!;Z0jZiXI`u32X(6b2cRw_iF6V%EiPq)qI!#y`B0U zbo<;rKR{H$m6u~KVuqqN$046!>&)0Z*j_V|``gT>7vow`uXc_X@B8#b!AHuIdqI2t@QCkTqhhF3nR%DJ_-r zpz(AkLhfvq|Jn_2D4u0-N-|k{rEgfmN}E4>jt?zbQu0}XzzXhNmmOd2?x%Lyly?a6 zo*E}MWgLe#>nX>$mN36H`F|W? zXVclx)|f+O3PN^9JE;(av7fHZWt>1IU~9up%#pN9Q&mZrHH-f3s8j3y;G=JWkNG6>A!mqC0V@PaY5x}_Qlk; z$A%4Ex$7*C6h}q0CT*_U=~1X~_uNmJ8d}H}gM)9iUSjBCCsXevtMn&utX*$e$MOBE zc9!{veYt}{U`3VRe*JQ}!odvmOVM1?9j6j)Wo0}A^ezYPC)=lPT}JwSckt_XY*!ZO z=dAM1sDFj77~M_h34g`{HNA2BHJX$%tTR!Ibq(fYQQzuU{*y#A?Ux~}Pz6|rnj(>i$ZARa6*f$J|0y|(yIy@?$;?#n$qy>*b+U$j zP9x7Cc5b9E_AL0`bL}P1l+joVU?FI9QJi7!^&}~ z_~QKHpwpK*Yy-(VsX!q)c;jlseHD2suwgFp7GRI=kA8%1a_Y?e4B}cd`~3E~+5gD` zXd@Gd^aH*i9xi-aNJbWr8^ece6B$gh$s~8Je7Z|1QPwN`;XHk&bWX5u z#<(ry$cRC#P15Zo(3dVR`PtPC)*N1Z6V=dQ+2DWk@3de8FM)53d_E-m`xjRzoOnuc zwH`P;t}*Ili%Z7QK2b|l4bQtrSgUJpdh{7{jUtoYaA5S}sgqazK}*m9*UGlv=|Ly< zx)@;@pYLGsTq=S1I>3^CnVbW(u+P2n-ckVmQnCGhGmw${nlBDMhZ%6Y&YIIvy6N;ox(>KAG($tkKYA-Hs!HI)Eua zjrtaWOV+9+?7%8t6K3kPb-OsyDAJ(5GJ5soLit0LSUciY@hiJC;%C4;j~~`Rt>>Kt zzAlDq;qV(D=dQ#%Q`4i(>T|aW88-lo1?XVI20RnA(mBJ*c-m*?9+?iC@@@;}-O)Ph z(%2mmGQ$Fj=(0ctFzJ;xgEh`w`E`L5^fMfu_B+v6}( zA%K2>&`b^4oZOX_k7TALW;$jk1ec;yQ`b5nDh4U}rZPw(cs<^kSL@3}^Cw}wFN;p6 z+XMzwEo;VTrrBIGobm;-h$%2*u@0REJ^kn`)S-8IMycb7F(*@l!!>ksG%VNzJz)@0 z!8R&dv?-xM$={Z+pRAJ?`J>97Bv+~8`{s(pOu zZA*25!eU{o%@L}cXXJ2y2*~d89>& zqhDzNqeiY9EGy(hho9Y#kH1h%Kk0)=pHp}gu#={}1}J}vK;8I|1qgpHaw;t=$3p2?9#bpsr zvSUI$CrZ*9XAXxMf2U-^LW>r{Z9NgQ-UV6+w%`WVeOd0C@FTrdc$nWn@U4jR^p!Uv z1^E4KcUNI_o*w2z&wdm~Y2Ow!j?Fe5kdETKE*E}(?c@HG3*G(HV>YlaN?-NWv77#- z08igwPNu57JbE4dBIVHlxZ@%dY_vdYtdsOnU+EZX3vj0lifST3i?BHuOTVy(dzmGP zJ|@;OsLG+$d-aTf({xw7DPba2Y7}6gai1r&h7!7oeGYCLrM!D{dpwxkXLrZ-y}`tI z#{g()hk+NLL)!)L-wIDczzIboj=n^4i5k+ju`s3Ej;Y309XHCKFIXI4@ah@&*Biz7 zb9U25#)>hE6((+a7J;TnV$9qz?YUkkBojJqLcZmrCf6o$c_5yBR~7f!IFmCOFthW? zX*f;uR$%RdMB1KZ3A;Pzc<(f*i6(bIdPUn!Y{Nsu9`P|)Fd|&^MW;;CIg6;r(Ozg} z@xt`X6wEb}9AZXyKr$DjVA0t27#nEpXcT={aDK>{6AB*{a{g-SUAv_<1a%3@=#&GP zJex>(KDR)(oH*F%#l`1<>yyceikg|=WM2tTS#c_Y>7ja5nw(medZ_V;JSOp$k9rafYBh4bQ~G%+PR zB`EWVs6TPsGKHM5ES*Bz^YRHN`e8K07gTNs4X)LXnvks&Z$u{zFQofpEuDp2E)8q5qb+B;dRlZJgnfjEPS*%Ds?rYpT+eXo60-Zx4M4 zVydN(KOt^g=T4C@UV$0f6J#~SvEuvCzZI=XjmZSCe_-Hq$q&CV2CyIk<+ygt%8(ewO61$^+~q2NQq&D*-~tm4CU8p_DO3vymrjpvqX&!?Ukf5O z{vkKI0Eg_Du)7zq7_<@v-3*!jzMN&j?L9;5YvzME+uSw;b5#Eq-~C??2YVWsj)G{I<~H)E8tZT;|r3J}oa!>$G% zfPyhvr44WAX37!k*+YNwoe*-%ej?NMC$G2~-Ys60Bhy9s8bPcl%R+hjWW*^emG>Be zFSgAn@QtsinhZ2@wGlEIrh}A65`aQl{B^L&TCoqDY9Q29|NGpQPtF>_>AEqL;}zcAn<*p04agPMX~ezA6s$z-jP7*dESt29UBtBjaSQ z3P^5Y)hG!wf8#6AjZ92a8H^4p-OJzJv2r?!@*@bdv_Bcm zkujmx`Ebtg$yj>BSk}g!^?DH~jt(YdJ~J9)?~1OAYz~czw!=S)R)sm`X?Y7|RNuBq z);k6iqK2~b!lm*{Jwf<9jfTe~jrN`no(44!u**R^%^g)ID3-&&8loqqPTW35NNFpN z*HxHa1mj=_47lmh@$_ZVuxtB)l5)y{5C{({5gQ#uJTTK?xY3}UZi+ReNN?y;6#iD1 z^69F7B>M!P>#r}eyH@Ff&%fFdW#CT-zd6RQg#3|M|NNOnXDPxV zRG^FltL4ns#g;mq`OG)&?+FDR`$I`KH+rVW1iFIB<)e~cKWCw1L~qmAs?+b2_F-`^ zDbExj6r&aHdRypC;4E5UF!vw@iRftic?K{<1Pl@j>&bq;AekM9<7L|UN0|al28OCF z{GL`-G$jN_0IRPZYoeGBHc}k)u@5G*suGZNCeH%h+Wv~0)3A=xrSL~~Kn{RAmnbpz zCHlL)%u^{dbp4V7T}0drv@g>Qin%>qKN6qtbOHtj%nOe(A7QUu+U30iT>Cs2Le(D+T8VWyC4hVwvB0&YN1<<2>;OL2|oJ;7=1{xt@Or4 z8!R)Fqdov);&CmcY!@-2-ybjTPqiotm@>GA7P2pU{Bjw;W4SUgcPLpuD~^8$Ls=+S zsh6v+z`jkw2cbLUJz)jXYt8|RNCSp(jBg2hUZ0nybk^i;`<8lt;=sU^)CP&5i+|B~WHmpJsu#-n`cFvzRCg*yi87{3NeQ>og52Jv^x4Q@W3 z6rgVw5L}w+;$>;eP9yeg(g73T!tH<$xy1k~b7z*iJN-yF&Ls`vQY!rvmnJV}rlAPi zxr6%LqOTgAOsYYs%JiBBdc9c)>Qm{-7xSfQZU*KVFuV}-wm$)8L z0_-TYGMd!euOPf0yI~kOhz=w^ifI+d;#tB&Y}7UdUqFKCK$E0kdQD)nmBdjJXk@{m zecZV4Br`8i=BQRgGd20zK|Y5xT&`X7N4A|o%rFRE;B()Qc-xb1B*PEd z<}+4>Jl|TRs>75C$<#(TNn(X!b z@dQ`O>Q#IbSN{t}wZDR{&QlMT>-O}ems=_eADj6_$y;+Nn6wQR71#g|*>Ojq!9_D?et1o8^diSk85)dqdaU0oI z5|XC{$yvJi2x^||()`bq)|yVmCJL}49W6!r3HG&Q?d0}0fGFSj4w~3}eSIBquKJHb zDjcPc6hP?zR+`k9sX}{0aK048QT>CRjgb@)^JRvoxD<3FUhg~UoR7-fla>qvDAyNL zwPFLn^T&=MGC14Nm&KQoxhv$or!$e+2D0+hOwC@hYi|eZY)pHqXoisr>7$u9fmedyuv6~cuU^5ifGr(`+d4+Wwz-_qu8A~M zYK7f7b&d#mkP@FBklC-jZyw3t$Nk|Ut^1Y<%e6CMqTW96`NcR;INTqZPq;uGmW5ch zAwOyb1)~!$MCvF3W@I1e=hgRjx`=*FsYsZxWfW`y!wo8Eac;sHJPaoVEnBrxQH zW%f11mET(AYEzK5h5#vR~X%oNf) zb)xN}__~^RO)9p$^%(>Yi-+9NMzXMBqeq3JDep9uooGYC6d+zx=Gh72o%0+-+6IVL zEZ`N#6?&PYh|QRT@&z+rw}uV1gs6-tADWJ8w!bWv6dkkYtjz3R6{7x;+vn|TB)YPO z+H^P6>YXbW)IUVbT)uPLE4{UEXqLTFmu)({}K8Bc^m~ zS>p2TmDW;Z;Yus`d<)XFNVC{Gp3V}4fSH7+aRJ7FIiv_Tk7>QV)0J!a`8hAb3L-sq)?9bn9@O=mRe>2O2=@w1XR4s2zi-hVc7vO2xq29PSn=x#mxS z9nFDyRtwnyWQrn|()%qH%x#qV0yMXnZaM~Gd}Xmww`Pje5M!XgxgU?EjDZAYjA*~s z!oTs|Fbw?gj0s-dw?JDli8F)cC+3puI+?JD47G-;`{?)fGViBpw1Rh!T&$=HeJH4q zvfxGQo)9YeVhy84vTF)XY%F6U(D-@!mr(KINs4gB)7x=(as&E-y1E6PwIvrh7-Nk? zR?)9w2s~}iJ2MEk60#f zMjv4gpcfc5{Oh;uwPZDMh5E_v>%fe>z1tmfaAZ%vYQqXBYzrs;teRon^hXT9uO#sF zss&cl{b^l|bBfKb3XlJ4i}8=-Yp0k+9T$`pAM)S&DW>g9eumwkL>hZ;_T`G)yDx;A z_A2q7;Iq-8zoRTDzhhjSXID%`nOtI#2UkJv6^fiI?xi%NE3I|Q?fHHynF#tHZ#yoiBf2&XL_Gpd@81h;LgZ}BFiPDkCP+Tr#) zbcoQiNhirFv>U#oQfj*NR~@B39wfl}F6R_YVr2p^L8muJnZAEF6fH?2k<&ofAy^C) z&T;vuK(=J|j~Pg>KttY><6U0KT|AFlZRWw?8jcYsS(3H4xq#`LteSM8o~6dKS+{L? z1X1p&5j*f`DTXVRE#ivWL`~Nn-b03^Ht&&zRr@veZxr+HZF3v0x3736fH z{C@b}N3YyqVkegPt(&vvj3@PmyuL?CFmja4z>n(QYG4!2`Lb-$dj2GuUu-@}g4}fd z+!OHU)r^54%)|dE$^>E4Cz;`fxCdHM?k`e+OZp&~D3OfLN?i}SC`{=`!N_c-ej|cp z8SF8}iwulpp}(@~E6`WOCU;8+OEgme0~YN|sVXl61HU-mdVV?gG--$S#&n#r4(c;c z_jElvZBZj&_taFYZ-A#z=4|Ix(f1MA1gF7={+?O$ixLc}b$jQkgFiPay2o zY2?|l#Up|`V<)kY!cW4~v!CD;WjeriL0;{z21WZAMby#z+RmPLT`c<2$0)mdvDLOu$Bfcew>^(?6K9qUk;2r&CdhyiZM5<4+H&bnch_)2K`v)xEG(W=={WO`eEJZz69JZ}8us|7^Y0 z*wao?*A}ts8+Ivt@-r=>=C5RaIMZNkC5Ka9PS&5cT&U&CJ}{#*t9dqvGxSk=y(6*H zS<%6L|Dd{X#+8NrUq<9TR|`zYR0OO`6ILXUS4nO8^sa&_KQAooXtcMZh^+f#=K->H zbJarrz~`E7O0}G+#RtZj2kk=aBTb%lg51lOh2^}-W4BLCy-eF2TFps+&;^emANpMX zy`5tKW~EUo)KM%4=~4oBD*7@O^6hvOy1P{Oo1sMF?V;tEMFJo>Ez#Vm7oFWi@5n)- za+@!f<2x8AE^W=CiOT$hNR()wsc5I$c-RkYnx-F#CepOjG+f~ z#a`UdcDQ|mwT}ApI&l~VUa>j0(|3J83nUKRr6i{^`uar(akwv2Y(+TL8j}H~yy3)~ zsvqdE9^W}yI2!!ajW)^qvYC6Lf|#PcR=U?2xhgbwi_oevWRCV;`Zh$0NW2rWFELG8 z)QY_oNa3}ezHUrt$LiUKQex;+SlMeFJs`a4K<(j0GnhKUh<>%h;HQ<$Be% z=!1-+IPFF@k{?{lk~n%VWnThjhxkC+L;%GeLDAdsxioIkmC2f!2{uWQ+++VZ`ntkK zGU4oCip6+H!lJ0080#!)B6Q-YBBUU+v5l3CsbB)lf&eRKQZA9x#S8;Kv6$) zoKh37su&PEtmbyqpw~*cZ>E24#FuvsE)(BNpt-Wj=sgG+OUIIMkP%k@@UNMa{@sVS zakJ@PxW({|fyDArIhs4v>Dm+SbL8@|h9~7lWjlx~pNyBYQ3r#ctu$T`-9NCi+v}c; zfN`vg%F}|hP&<#p@p-$55|rCJ zuc|uC=$Ljg%j1@nr(do(7MO>zaIDAlemw0~?8|K~qf$D41KB^G&9X~#v;!*aR-6x| z9J!8K<#;$xMnc0$3K|ltD7ikdIgN-9#^#L*JIkX42{UN66MLC|L)O5m@8&HVxCGR=c8YVolMWf8(x18O0lXcK}G3| zfXwjYC|j6g?!IRhKV181uC`&n4{CWniOi5(NQ#H*)YIMblmc!sS|i}C_X8EfCj zNX^lA?d3!w5{cBfsolAmR7FAak>V3BII?uEc>VnTLE_UMLH$+*w0mlGYQCuD8E^)@ zyZf}$G~r|n<$bWg*>4a}dc?zzZk=+{C_DB`xy=_AQNkemJ>ht8v_U-t#@xJ)BfJgP zLxFGAicsm0UC}Fo-vlcT3jAgC%bMc3LpcH;71M>Pg_W`;mFngy6H*ggl zdI+-)=@gQLDp1w93)+M0kEuBq#W@1n3_@)8Bs-b=3uOp&tBZCAO+Z8_Tr$y-lOzwc zJ^4jp0JCYZSi`=QGXj%%p#m+$kVQYF8|S7;kh6lrqn%m|#`pNjKkQ*B=^oT}grbmr-}k}T z*Ty83D0?$wAF>;aeVH-*-gEBn-tW1$`96O4cmC?oL*w)QykF~ckrV^~V|#*x3s}(e zva@>(fxNUR3+rG*oQa{TIn33Uu%gSUBX_qEH_V(gso~5SP$ASGUFG|MAOrTMh$!H9 zBxJ%mkVNe3la~!}j&3hufGJVj!dtD8iedYK+gf-l)afX<9{Dr;*uTY2UxkZCvmTTf zZrd*fT^(8M;}#gDDFN5WDS-sR1pC$`Sm2bZ%;P zlg>3WbE%)ZVll~gmdf_j7YRm02J(!=#Ry2#G+x4;IB1tFXK1HCy=w-y-|h$%zG1_Y zq{G_4a;_Bib(Kx7e{Xnw>$rEUME+OwTKi`KZxXRYza!#!rd zxGjV*SuhcG|GviiXYtd&Prq7aJJ3tfwCdR(Z{JgHW}`7i^RAUoZBTAFowASilz5TV zC@_T%3@!Re_++TaA-m)CbGz7={N-h`UCRf}KotUB;+v}IZew;T)Lu^DN+r-ANajYq9TkSs| z)lU0f)o*715_lWtmi((>^Q@iEEPljqf1tv8SV5osHOe>zRxpgnjI~`M9?#pMR8z)6 zgA7}ZWtJZUar(4ijkd zOVVfE&wf!E3f=g6AC6Sr;hsX*O{xPDcIx;gn&9*WWjdxMWn6nMEmBs_lZ$S%VyUOi zT9y?s$0H8!m8P$$+*_`Ffnvly|CW#wk@Eb*G;4gg`laRlg%PF~Y!FiGO8h$Wt5NTh zC$;I7?vJ81Ah0UPBH&A=GUPMp#h<<8Djo?aGaCQ z|4Pnk`j&yEv$x9SBY4lW@2L_loryx}9Njb1drM$GyF^FtHzLxn{I7`ie_tp6Y)Hlp zMf5oPSCI!%NmoTw$4Gq_$9-3AN6{P z#1`DcDf2~3VV$O5Ypm`jn47pav99#9o;%YP*Cv-({k%?Wjl9^oQ?UKqQcf#D%yj&0 zp&di&@tJcfn+?7iaCcfbg>a37T^>2%8i$r>$*NwY^S{i=sA1RFY+5~+?sttvQ%>L7 zL$)~^RmDX1qo}yrZ*`CdmuC2$!4?f6aQkp0<`OE!;%E zBR>vxmgZlzHh<>(!hy+e^txoaJ^M8*c?IV{IUR-yaen*v$@u?GJdQEU_c(L6I_zj% z)x)pOls(Ribwt41=b{F&0=qW3CIGo>(vxAYYbP1sdENfHCCY;G#48@dlR!&_g?hdP zt8`PGq<8e!hL~qdFGy%IZ_bd8^^KK6r!eY>16Mr?F1NM*jD2NM2+hE4+*V~_8A|q! zH7XzF>vBSTLTnAY4F>h@^$6vRf831vc~jl`o^JE61NvWe^rbti&U+MH5S#L`y1ja@ z&<=7wyzo5HDUFsc$wB&p?eASQe*Ks7v8M`7atgGL%4)<7yG5Uwnn|vnSsD?Ldy6(q z+HAZxpX!s8Q)4gaWaai}1Ms?nrRLMgSBcMjo-P=bQl8Xgh*5P7K8__vai&s@1 zCl8*Aoh8d6^?nwd6#vk;IgXr4u zVPhYa-m$duTU&9D{)|YE4STT*Yi>r{T;gV*X7PAI-28J zdev@;cs)67|L&%5WA(k-C+{=yl!rCn8(N#L_ei>F=@Bh@SHqWhi#eX3Vo_ikVm?0I z)~+uXZz1L`vbOUoq@>%?IYd~c2@&!WT~<;|At(^S_>ZPu_vkXb>M2!`vw~?=eLOU% z(TK{1z(o!l%q+xJjL#9x@M1jPh6_6{XrP==6bu5)5n_|jI{$achkpbl|7>W+myd`a zC?uQ=$zOHQHGQ57W1$S<_y{|R$N!n`{v!nzJUGZgso}&w%sQ2oi|Oa87*BurC+j}c zY__&aw9s(=AwU0HLKE9US8O2v)`M|>>AsZi4|!LrHIK3%!-gw)235>`;qJIxXqfa< zI@A9O_Y>ux!hD(A*$Hi&ga>QyuYUUq7jQ!to9&ggNQ3t&2ivB8r9K*RyWi`Iu%;Wy zif0_^k$M9izOB8wH7UNh@UQ9D-_G-bA>9|ED5v38#|W(~q3z_)E|Qfqm5Y0TTFP$^ zh0E|V`R$je5wBI-F-dH0I%$&xoZnW7d)dy9q)^zbWSk{b8t_*uR%e_IQPxhRhz36QhJlirVd|-H~xi95_ zW2%t;x36!Y%Ufk5+1vHE8s#_r%G;!?MEGR&lJGtOkj2`dxtwu)aJ*ZjKs5X&*Jmyo zZQaL5JU~03z^lGq_xD#bgLLFd?eohc={_p%0!G)EuoPYyN)V3-n6o*d+)U?t(QsufWIogR+*qqFN~~|6d~TMN@<8Yud-cR z6y&$TI>C`Ij%+QJKrGWpJP(5HjY@s-o+N7DoCRY zy>qY`@qE`Gnd*tBQqScsL(zJ;8qGDDLH!K# za@&{eWu=)o8gF;h(e3M+TB~jCwmFQh9P5&O((Sgrs#5Qp7~wvA$llWTG?i(|LD!{j)FAe^4E5hG@Vb#}gcaAeCcg=a`Tg=p z8Wv!Z9yE9-j*U;pd*_T#(`QfH-B}T#>FHt4YcVzGW)s-F%4~|V5A2wV4;{5YQ zlcjV2%eDKDTlhbPgnSD+`6|UM_gNUjLOZ(JTlBj9t|_u8*w1Z;2|ltotQiAjtw9{F-XtiGmF*}L%A6zUa05Qkr3f`~1yq;s^2xr%L{`(@~4vWll=53wH8~!6vP;EA7+o<$qP_nBb_Jc1MVRn4sVn z3TXyaab`e>F3ev*d`aLd#N_F`4{KWeSUBFo{>h{t=MIUpaPeZ zzh6~qthwgkuQiSzUexVf${aoQM5xpB`W_#nw`;*Yrt?Spa+m5$c&7RrN+G)AgOjH7 z2_fV=$Io%aDJI|HDR&dtvap1=^x;(+(djpDL$!2j`v;HoF88u_dTyl-bLv(sl{=*J zmPmfES&}S-`*Gx}w|sNIIAFDFdX<8i%4#;DmD&8WbuP}&qTH4)&^Y`IKIZ4S_)um_ z19?_tW6%~<04VybtY0Id42iQY z83H`HcQc+qvQS`m*^7k96$0Ltt+ecThufwCDrrr<8Xr0AwWD|YJ z(J-?PSW^sYf9T9ITaR@U;_I$X1@=scU6}Jx8PbgHj~G~KI{$f?{pRy6*=|BHDC1Uz zSUpn)@9bFW3hnXdGncit$+3dG{e8X>9hq8*B}u7s(2UAgw5TA2kPSZoo$@?0B3c{z zdsU__4*>#OJf% z>Ue3U>Xydune_8??ur!8pB_y{6b6G0>M;3CD>wi;|H`htFg9&Yv-?0jSERyaCiK*g zQ(g~TT~l^V8&UkpPspCWxBZ~E8f>|kA);Rl1Lp78eM3mSM%M})O*Qn+| ze3W4$v`v}&U0iYTh^#N4cMI667zdO!bONUJgS&U{7RDPOpaLo?D%Ua6J3BkrE@J0P z7pj1eGuTkK0TC8+Fz-G{_DoNZ@vafJ8`SRY>ze|0IqR~9Cau=OV3qzRJUk8{zuKwN zO)J%=&z?v=`qLsPyA>hkm=;$_A-AZu+vui51_6z4fK&$TF7-=7oD-?kB6F z3tPn^*_|FmHEv{>m5lOB5Vn-7NbI*CfzrF<2b+$Z%Bs8h_hy*8{@Oxiz*kv^BJvMT z5HKH0Vq$YPtw_@iW|X)~dTe#Z@KQ|}JMFH@KOuXqrrw6eUH9*%BOO6@b3bO5(Hz)Z zW~HRbtbMOQy~Nq>b2n${Qez(@?Z6N6?j#QgoO*2~@+*w^|HAN*q^!4dgY58$sgTQ^cB?SH_@T`lup}%Qg zI3ut~uh+8)G^wHi+Or*KE?s$AP~l>UJLfbxIT@p3tc}|Pgo(rxPF6%kL^HsiVJwrt ze6pNd;57qH6nA=7wv_Vk zCD)&4yS0$^{-4kgnRUb#GzNB!DUNvOxXjHt_qvP+70|`o5m7(hqpvF8IcpF8_}20m zjjJfXaqil{)e}YNqaLq+zk>g$9<_oPPMxw=(bGeeEscHjUyMo`e4tk?X_zD|(Zg4D z%7~|U3qoGsy+?fke5QOX|j^|g+t5<_3BaV2;8yGm(8D5?j^H@Sb zZ*3^gjd;Ih`}HO93Fw#6JkPG`HVZv>x<+_u&ufZ`iO#)<`8dR zk%ikpjx8oamVI#@J(ssH3Gq$6Z=*c(F6`lL_1m|P+nYW-U85f)?6phx&mgWE(+m2d}zQPhyt?b`f|}C#px|4vmlPZD=IO= zbaBi93Waw2O?j>YM1*r563k#kAgyRfg5{!gjP8vi?GZS~tJTiot>2HnCMJ+7d=r~LPZ7%S` z=kH(t7j*R1*^Ap8gRi-0ev^7^5VebZd<~y;QW6r*Vnrq;$`!MkmS8HM6yAG$t z!H%h)PaHPHXZ#|gqc3s$IEJM-w;v`rg-OJ|FKV zLzq%E$xpwiX{Ca2^H$57MA?t+0cNh62-UWH(2x&PIZ6gutwXPnq+e*ne)eztE&1xx zWi9A8B{pr-DssAbR^3JW_Z)wpdH*XpWjsE5Vz=a{;PJ434|IRaAJ^V|To~|p+Wd8u zWuHsdV79!9=b=%#LS?ngAiJ2X(LC19VQ_On_cBP>3r@1bfhccW$Pi}T1~c!P|EnB& zf$NT0Fn)!d(Qo42_uaHL`=8cdWW0vQMi#c7A{dzn$?@?#fc%?x!$?}C#$o?Uow@nq zrd0e$Kd8Ve$pCLZsBTUw#<&Ann``Mlt8b1#sp8#w6PkH?#lD$9$z!N-T9@) zjwN?c047*BZ1#ieqz4Q$r-Tgnxv9c7y?~8#`h*|YNvQon^=#an(?8GtQ~uC*_Se{D zhq7*W^QVF~fA53*i};N}@4duX=1e2Wg8riv@Nl~-bhvVmDHV|;(~s7tCJ2oKJihZfb6gxj=#VAj|>QB!AiV#nku4+s60 z$L^k&bSY+hSQ`(g1N0K!y{9R@0s4;KeZjkkbkVll1{o-jxmZ0Jl#_+tRvfN$35 zrHOWLSW6!}$q?mL4O{_~g|#UW@ZHO^=7M!&bcC6CIqk{03Ji#W<$rCO8eY^2x->cG5KW103i8w5hxU#WRWDGo-@n>!lxln*w92_ADea4qTYr8dnW33%fRK2Zlx%VDj(wEp(JJ*lx{ ze^5^#gU-pR4QjknnBn_LLRTx&5)z2gVP*~m^~cwZS*L7BTTS>}7Ut!MBHGF6`YDcN zV+P|jnZdx0bDj035vY4W66E0NQp_F$Lr8IGHBP^|PrY-QkwvmCH@=}L5nyMTd8Muf zEIM7?8x6+%=!FE%stT96kGivEB&FoZZ*BMs;(M+MN z_Je~@eakIp1XRgy>o)n+FAUfB8QdRQQ11d%g6<2VHdwV&r%ssxEFG_!%~sFY9Mz4K zRpf=wWClnlVuF&pO-I z*47IIB5o=7&}z_sKb)c{`5oU=*?iZ_pfv9c^+=f6o)1}F)JdFPRl zK^b6AaB>O?=%wiKy4XZ+ee}1xG^47f7X3uyya{P)iZh1Tl)P|zIxB3CM?Kjc6gwX6 z2;}CPxK3}NABC$j;N?v6xL)ek@^}lhdVn4dDoBpDy2(jWp11qv=!4)kKzPmZ-deIy%VB$ZxuKgo6#xirO0D+Q<}E&kKl!H? zK$jID|NR&Mq9#(AFuQK%V$h(hf>4sgTn<+o*EYvY0FiEdXc`(1HsG0qMU|SyUty!OH2jEEvi2~>E7ywPZcPE7g zVTVRts?`QY4A9kcucv6pCo4lUg57pnl--XQ0C;~<>PN7Hodnz3SgMCvtVyj3fMHAl zl6wpw*S3L@v3!3dk$6Fnltm{Pi_y%3mrM6`GU+JG&>OQrJ zFbPWbPAadjPrPIL^<7?6S+JjoEiey{c-oBN;1MW^?E~St^fp__!xzn`SRSf6L*%iz zOHqq?$@ZsMGUr7BZ{b03Tpf~=ggH|6tl01i1o5%KVP$1y`j((z7fKg=C~23J#Kh}* zARF5Q7D)DyL2u+ZNFVXQ0ZT>r3V}!*1x)kj!LumTasdCWB^hN;E&!8T>0GBWpEpN5 zfSJraPatuWk_YmCCDnzX9T4+ptCTCUw|oINs11nfT7GI_m-PcIAf=%Z_)S+077haS z3|6g=EVQjY~l!H6`&Wb!d()qJ;UHS)ioj5asm&tPV%CpG?-wf{Me zb3R#MVh%6KdT-*OR3CDMugQ#!_^IiE$YO!J?O|;XFc`AdA_8&SsJCyA&A3y;FZk$n zv!3vS_wr4zdz`)L1BD{fIC^f_4W;o=$a1GQ6J&jt-h*(KxW}*4S)&je_K8b>H9W+e zv`)y?LF$+AH64H8=U0KYpwhmR(rj624cWpVJKE zeDcU2hKeJL`fNK|;hRuaz3s(7G^T=V^&QK$EbBP-vX9CF&3{%5?PKxis~7%uz6}dl z(vc&;HvX{{FqGFs1G0G17_#dK;u0TUdy(qM&y-%~I=vNft!*rRyW)8zf`wo(>aXwZ z$Fem~cnoZ<0ATptTL7idK8afcJTH+glP#7R-N0H&JTPUP0w5i+Oa~EcJ*DI>|0Q4+ z!IM&`xY?uH6v%X(b-)3zlr^|LF6Y1J{NIArziKOLs4M8D=A8BKt-pTHIsWZewIb_b zzvkm%%UcVHFlbZq&1|+pP7XSZuDs$ay*Zo|Xflg0jZsS((DU*D=1KaWM5V zz_`ciTDxy@A&OS;e6|DU&vTe{rj&zpWX1s5H)D8!jJyA?K`+)iTN<0usu<#TMZdW(brzCS6EYA3m6z-(2{B~!&*k~Kszb`|H;%-vbFgbHE9E;z#q0x z6VqIdS6jO+%MSrP2Y%qDRP%}0C(~d61j@Nu-*Y|xNEM}y11ibibYQ-0bIZ;IBA2|o z7+>wtJGW^x&NN>A0#HvePfb7jy?Fp#_O}sK3-g|6@t&9-tH)pOyQM!MAL!lf-{Fe& z8zJhnpuLR99SOHvDB7_Da*I_`Db>6@nAk_zO)lXh9t*eZRaFn2m+>kjUr(pt;rr=6 zyDLxA-M51C)HN&kt{*QiYH+WnOLH66RJq%HNw;ZN2BAkdGdH~R7~xD{Idv}nMn{sH zSVUHq!a(_i&dvsIOXh1mcZ$cT+&zBokii9Ghl#JP`ddx?`HjAKlsDd_==2b$MMs08 zs9o;?=c%D!R!%;ka!|Wdk10YfB^#d983HuGsT40@w4~Nu1F+2%&Qq%Sln0A%*+tqX zdi7)gB0Y}FZj=c~SRkx=KUT^L?;;?MB4$z!qte8sDQAOxjg_udY>ECMBU3<&hrX8C z#%I3_Y!L^DG>2CUv7ibJ&PJ{E}k z>zl(Q&(av9Ysbj55ERCB0;FS_RDc9XNdbt*go1y(^M|aPKY4o*y_%#0Z7owpG#Wg( zxlX$H_+0V-kX~{CCS>U+H9If7J#YkbRaDfKZKJO4j8)&Xnx#9-T3$ZfA;{0Aq}$>> z+dM^{m2=R6Z}+AlDd~dC4~E{{?`%+|v58`N%c>*ChyxSnt=)Jl-NBUcrh5SnCDZR$ zw&&HgG-LC#dmpQvCLkf9yGO?Fhce#a1`d**z@vHhCU^oy8s2~+%)`t3^^Ra9FsDuq zak=v&^}`40^FTH<0cAJ>Y_w0E2X;XBOcekct|YlXa~g;qtH}a#sVC5WR^Q$Ne8gLs z)4*f=+E3wJ+4SUO!jJb+QNI>+zNYT(`tTd0hl#yZ&@-r^-UE0P6r9vdpK|N{3LYLF ziII_aDfemOC4P(h8LHH_u2+yd>^G#xL3+OX@@r3_<><1SVmF&yzo#;DlL8skY+&gR z+%+HKN}JJtWPp03!gip-*|hs5po}hlx7$Oa{WO4!!3pQRCz>b>b6-v5hYx|!*olz9 zO?5>Hjck0Xl+FWZXPNpvlBSYk4LdW|m?(z-@SNc|Iucm8CMbiP zP~`wGEd)rReHmWG;B92p?udt=zP+7d=|oO+urB7+lPml-@f(s&993xOJ=wi&gc>)? zHJ!XgDlc#8a;NqejB2fa4Un!E&(F%v79bMLfpmt3evVX7P|zwMb|?uK12y538^`l2 zgHzr5Hgfz4w(AMY&Jcc&H!(4B6|mA0{H8WqK|h&VDDwJ)yWQBn zhBePG&?ib!zB>czu*4m|-R)EA17#+P<%p|57}*d=QS_YZn2;^dt^6f;4z%JSK!|FKRax3$UxV( zeq#pqTE>Os^mNYUlr-@djv;IxQqNUIq%91(Eps>H7s^N>Dc$p9oEJOW2L*^|cqL?f z+smpaEB}ZzFy5<#YwuBNcef`9&@v(Xk%LT)!NEZrC#?xT=boM3g|zTI%%fj4YIOhp zIsgq!420LL#;T;nSV%@bD_^Eh0Y_zK_Rn_sQK5T&fGiuo)8*hfia^&><(p5xz`6k6 zcQ8I4ft4BiKd*!G?^$nefu_GE;FqG=RC}yfgMsLl78R);Xmt6hywvQ+Ti{q`qU-_A zwnAtf7?HYcKCCp=F~v$$@rnGG^^%}}6#Q|fmxQC(?`h)z-CYI+CcnjTPj7b>Yyet4AEhUB*t2@jP=kM4f!0Yi{$pr{fMvKYRWsqppE z)k?{nd_j?jgarCI3;}vt97|ptSUbD3GgDh4!WJx~Ucde(?4kQ~N5=?pfjk8AQq?hT z+2NF=JLaJusPeWAu_{B; zA{e%xWf3IArKbyO%I@T}dd+ksUw!lD%_`uczG1Y3c`u5bj&eUf>vinfR5KU@q@D-k z$64^tQne_}fsOp0Mk4NBOMAl{`u1Ma3oy55gyt^%Vs2SFEnwL2lX5GILUp5?gF(SS z_hWr<-@tD%>i13%j9|TvfQw;-yz23{Y1Ti}eoXSqX>(R4!A*jJ)I?(tJ)HC1vir{) z!;29MkLyO}LgaH#Q7tUq{&8CYR7>WNf>ehhh&7L9$vVPcS_YOj#1k*=z`*VTganDK zggbhd#CUQu)zbY?g%>VhGvp*W;k%HmyGA3VO-i!Q=D>?e?%4>74vn)c%mnwvYVL3P z4u&R0YmD>U1t`OW4)6^OkR`2hZL2k@GLr z>D`QpA$4{iV%lXAU1VjyUmcjNXI>*6hz8=%Lh+arb2u1N=@}V6pA|ykL0y((RQ^)d z1lU-Oyu9?7oW?igvcE@}0{mn5I3T00sRAawF1AD;K)`eciVoiZpTeB3*9&>hz|r5# zk_j1X7ZEY3i+IIdvp1s%BLl;3CeEVq3DA;|*6RV2Jtdu!Wmy*V_*}liAB!2h_pwZ7FI2|9txY6QF4PDOUMn(dvz$SQ_6ttbNWO z6}G2iXrKFO=};we_OwE*ynG7^&`@)4=-2#sMOxR5_%5|h&X=PY>eWcA4^|Uwf(|2( zUw0aIidRV%wNwpcOf{SBK3<}lZ*{4)pL4KwKYD%6B|_rHHSsH9XVuFz8GJ@7rlfQm zH(gw0d2;>hEu5WEFDZvoy9wCRkzr=b&G@xWatK@1Ua!}UGQLXLs{(rKRdl%H-;+ev z=va_tn|jqr$u^u|b`})0a~DZ4dgKm98F$HX(>cK~U^-`&x6Jywq01Y1t>{9cy_52Q zcFk)>RbnGi_6d(pU=9@vPc)j_YJJCX708XeF8qZsdCY=t0`s(kxRSe;jm)J`3G6a* z)I-++j8>kZHvv-n%?lFPuhfHSZ$};I8_xrx8D4_gO7_VD3KSTf;=@mh^78Pg03*~N z1%{38x@EuI{&CCvFJIT%O#dVv<O9GUzwQuH<1Kd`RA{%#lip) zVG924DGgqwmK8l+$Rppt0;y>~Ei0_yz`0-ecAPfx^0JNA05NLglH(+OgIi-K^Xka6 zvl$rBL2R*KzxIvA^lpebws^=KQzVh+U|?EIaUqP(F$$NKv;xi5p>-jrhKhN8$? zcK$I?Z3FqZx2FNbpedloD3&WtyrE;b5&rq}m2JX&tsQZ611f^WO;WZTqNqd6NsLZc zG{e!fGKD05wG$G2e0l&^afw2b;T0PmlN+A*`1M%$zH+PX-HD?)pcMrL`P6ipUhR^p z#c-3a=eI;)jc|k7vv|(>dD@B%I|e>k+yXX{Ti!=XE>d?=2$D;d1lniEc?a+tQ=1%6 z8*!j2#aw`aeeNAFJD4#rAE3%l=Xkp4R8N6*;~Qu_<^+;49z&)0ve*=|CvXBcCpPP@ zDB#chsGYBW-q)#w*Vcim34-ES)rEB}jTnxvf}S>6gpoZ5nFwXXj=8W9k-U}eOFbM?1;s94ifvQGEASx=)0F8Nq8R!mHL9Z@l=kGquGU_9=e(n$Y zq!9Q2Zcy-n?slw)RWS`iH2Dm^qm#rD9(_FgNs@t=I;82HGpv!lkPYJVJvHg*g&;b1 zM+|eV-OwOLt(vq{R)!!Bnx*@S8i6iI5U6TbLr_AU%q2|%eR(WrN-o$sfbsSn$FYaK zW#dln(Ld7nBZ1rZM~QGVOX}>i%;5vIGQR@)V1A^J;i#8Q*^#CDcOo`rK-~yTb)=?G zawK1Az0d~(E97ecn^A$?LO;mof3#m!6$kC!euOqflkKDMo1wOE-@Lg!u7k3$u*k!Q z)=~|Ds3P}R!h5s08o-+e%>ltTu8{4`{#yF}0|SGUVc>lc_5S_1hitk(R8c`8;7Bk@ z>Rf?lZZ9(ORXw!%dc04MQUy6kjm)DigfO{`Feox5sCHGDSm1~8JV@EjD)?%8F_cm zdx(r(u)ecggg}=KnvBe_? zyE4L4Z|4MQbiB_c;9KDbvwOSHo{_mU1X;wvdB(I%B_cld@J1#)RR*re_{WGigteuK zNfTf}K0uI`thqyzM3PBWo?<>bQ%`tbgKQT6CfE{e1>{yqg!lycvQ5;lP+YD?s;EVR zkiI*7QX(Q4dIL?#xho*Hq=8>JJ}c8=@igDM zwV+yH1z6v8yL#G!bzcKO?tIS5JQDPuC;1znY+jeBor&vtf2 zchL*es-u4Untbeu0_rMy?Y4Ro+XvMTMmJ9VRse)Q`wP(Ju|KgTci~Y`OGp@)M^YF9 zjz3iRUgNx=*u~W&qJs@XzF5|fs9N!`@k}!rl|Klxt5y8ek=*#{ZS2PA({psndh!UO zsDodq?tKl7;1A@bYUs7wBNqtx?7M+R)q4W`RRPKMy#%LFgXqh5qT=O5{y=l=~t{4|4aoDww!g zVTx)c(7t46{)>`7vytSxK0ZaD)4>%J!vwkF&A;zWq;TCD+s{!7OY3yfU9ufqFF7Kp zEIHGi$*sCLC{Wxz+m+YgoHG$|4_@*E!KN!RmJA?2k9MGk65$jAOkKXuC>d&j0`l@| zk$v`STf;ziD-l@lJh>#^U)L6WH4OR&ld;&F>wjosue1+X(L7<$=>obpT0q2NtPe~v z?ZI9Knb2JTO|14416%*&iPctYV-_6&`(jg$FCBKv6broWF7|<#RMzrIHtbk$2J#*B ztGb$+@S2U=Geb1m4yaV;HI$-Vng@kHmmD0W7I*vZ!eW^CYIN{hsyP4cDlo~*8>|d| z1LTPu=)N?F5*LB9a1?M|-=^qNmvkk9C+hTs9ZR+>9oSE#30a2kf+#917vbI*{Oz28 zo*Buy8qZ%^_eu7P(BDU?7kKD^=!=BvQp^wy?^9}%#<#aBL}M&*@x=R={F4)clZbH$ z@$pBIE8Y}6UAejQRx;1j?%@iTQZe`JhFfI6Y%QFp$GYpx;%ZSZj(H!xICk@v!)PE> z+$C;dgxm<)tw4y0=#WGS%vcNUr_6sLVXSH{obgpPvwfM}^p8h(%)T8v{W(=`|584I zv~g7J@QapUC1^swSme`MdnqgYoWw41*e@OAm-kLB+->yQFhKiOeOgYF zF@GYW!+%s+xs`;{aI^dQaps{Q>WW^;#yhdmBj!ZZSIuUx3@1H2AC*+4YD1MxsvKYgm-bz4%S?ZTml9gzg}60 z%=!QU^~3{3P+=@z?cQFaSw!{B+E&W@_m6I7f~^K)*8WNa{F~x&88fU0d9n685S$!$ zIG+V`9m;)pZpOgGB=gAVJjz<>7XW}H0O1@d>e{vfh-&GUKMPV8*9q(ec%R!YvxITZ zT?}px%*x$M$D6Wo)FNmKXq3GNf^zlgtNr`nJNLLrYq@OxZn7+y1Vy+#n7t@Pj3GxW zDT7*zxWFjd)SUn5$=}Ln2#Q9;TutdxxhH9vLn++mDEx!+d%xg+`HGe<-=1}y4*uer z&Tbc;!_qxyEO_R4W*?kZC!ghN)_&K;Vcu}a2&?BH>f9l zd5qt2$WD$V+WXj?o>xPI9?}ri z4&q6WPGA)Vf#5=KeFuC(3ef|77L1cCpXwhy-5Jxj&L#uz4 zlpB8d z%52cR##6sO^oq4<`BEGu5n zm5nXN%wWIjbh1PM+WJ%QOZdS6^qygns*O4G9+XW8`{v*Kdo2oe$-9qBkfIeG4&T)3 zF~Q3toc1rkGGue@BCapezoju@J|V8hApCk>ZkKR9ivr&B5@&s|=+cY$V`5Iw$f<%GCNK}7Es_pTONl<1<7 zaqp1mD+2^vlGKyqL!T+oK-7_0B%1v6zZ>lSYo=8&PdAz_SNvmZq6ur9z8|*EmGvj5 zJe+1LP~1PyeYn)RY4p#gOOQp_u+E_}er|UDIib;YF1^L@4XzC%05!QOH`ML+y~)B| zyno43PDkob`+tKN|HoDS?SIFvo$EeP;vHF2fc|izuf>@-;m!pd9AC=p`iYIg({K~+K;=@F-#A)Q%|FHNB%t3 zZCqs8k8`POvNA{ePf41F_$^xMAQ(->)RV91TQ&)}d8Z4wX)Fn}*-3e6K$XHltt{Y; z*1zU`c29?y)y%7SkacyiYw4;Ys(FTeLN>sc=E3~-5dV3+MWruiW{y7e<}UrkQ(g{s zt{j(d-@bi{jm=I*Szqa)^;7brXh6 zqnqjr&y?k!b+8BbQJebi|Mn3MD}F>3AuSVdTnij~Zk_hskb0 zlIbWxzKd&nqd(UcVI;AWi-Z*#gb~`8?XPqD`2JMcq4Hc*Q|D<;PM#I)4D-aVU&S-x zSBE)l?I}NuTwSZ?vJRSTsn7iA=!gc(F~UydnX_kuMDocOM#-BL61h0T14iz5fw=Q` z(YmUK@7*|YZI@tlz8@8_)Yv4`MSiTu@FGI?ETxMaLbVeQ=Q3?Rt!{RsdiVXp6>6( z8m*uD_pixA&Mfbyn6wcuUcasq6?IC6u91DUqpM3SdaxPqSeoHGLLPGY{Pk%c){U+jr8#+TK19IdTGj~w8ZexX%SQIT5xBI5YWQ(_x_=I~-U2n|PQ zh6dFu84j{LO}A$_7u?hr2XJt1eqOS&)@IpxGZY$9D5hhq6F-Z-gY?M2;gj7)eHS=3 zaX8u5e=Cvy{)B18ssnVfkdIp~LGMenTfu+2{zKZQn=n07=eIX%C0X_d!Apa@WNfJU z>{-?YaYuP)m$d-K7nBaG+5y=;*{cthanN5hbf4riKGU%qY_QBW1fV3r$Q75Y?GAzM z`x+c|Vz3GetlnG=cRke)|A;e6r5Jp$-y$zxAdIwfA-cZI4C@cjO2V{G^&el$a6a0f ziH$0K+=?qs;GyER#{`U`jfQpqF>~>I7t70RSTn90CdYYM;Sq(GN?wG&C_EKaQ(QIaD2?k9d z&c?lXGdhkhIMwx8{#C4ZwXvP%iR zF#_GlC%WPh_ZQ#5zlbjb9Rq-e@vfjgFB%P~ToUS%?i=vHp9ktY>q{g0Sy|g`F^xxQ zmLC5Zg)XqrS>Z2oFaUn*rrcC5Zz#5*tk!}m#6=MpwnR(Vi);<*tdRKQ`b-u?!no>%PbA6L-67K zzC5fYY9e)frt`u6=Gu5jw%OYBmnpBdiUuQX z?YA3YfuEgqZT$Sg&#>XOS^+4N#{-slmA4d zEXA*xto_s1nUlUKTK@54LjYfl05eRop94PtDi&$5+ja6>#jOn~b7)FhCHySH7y!I8 z13{gPI!K26+-Pt;f5n$H%;CtHD^YITe0p`Qix^|U{MGA-TB;=G2xYzyj3(QFe2~(i z3Z4O=l?jP5x!XEh+hbvA$+Vb#l`25pclGw|p50IFT#f+fIM}!fMqiTT+yPIps8CYu z%NGH8Yhc?~seCX83D1}#r3<-V~P60%2S` zaHPHg)a9DY%*~bVq&k%$7VZn*x;I0e;bM@;!-K$E96DXytpU_Lqp7xG>pD`tsjmDt zZp46TA#On@SS~Buuz9b&>@4>GQQZFZ1m)dMgJ-Y9Pl0xHBPn`b!SD?uR{e2&l+GT{ zw$1ldT4k7)1s%HuGqFQjr(4AJK)<(D=Anvl22a$CK=Q;dn-HUX-!D;qjp;IJmh>l< z1Di_(nGP*!e%HjyF?4RNht2@Rx5%1{nZlAAr_ddU_T} zKFyj=etYjc6|2(@zyUIyA(C~AHA80I8F%xBiXe!|B19>GeujkAV1t^0K2z8c;8UtDc{e0I4Z&PqjkxiDhm>al%nRg*aCfwPNC zM|_L-7K@wOvRKTC8CpGt>Xk9(3NF^i9XMY-sRPA!nMDU0BM{PLo+IS>dBcfM#}DhI zNm=k5)@Jel@uTF9!|0)2BtDzB&U-Fb5Cp~o>rhiv6$z|uOOM_3$btOZ$2o$BTa#_2I5wGQbL-18BjP^Tht6x@VtFgCMi zHTKun*E3x5a&o?1&rf+q0`VFqyEAz@e;?Qkr&7E&=G$QVB%K-YpPj|Jm6JdN?eL(w z-82vctBTlCDKS!825v2;G_&u2G*#{QGXVUDl47i;_V#C~;rAPPMInB6kG8H{R z5s*ayTJQZ9AeY8c)z%*lJGQXovhEoHpE!+K9mQ(B5dbO9wb%os<<~~AL#?E`sW|;l z%ssMzUWV<`fD0*uB8Cc(1>oBY;(&6PY(`q2>jhKj9_tnW0FDE0^}5jFrzfK{W{$K{ z03<|Y{8{R)l)cQG#dmEQ6;R&&q*tdkPOy;UT%}xRwNygxu>MzBHvFahoB|rUfFr#y zF3}U|m^o4Oa2DrxUU=6AKJZ~QI~X1&XZd`Kc@Nx~y5p8K?~FjkILtS@lWmd(E(tEp zW$D)Y(vaX`J_n@~k9%2=h zin=<Gro0IjnCK>8J_;ceyxfGt5Yb{^{js9E7=a!Se#y*Ha< z&4C)}4?J9Dz5Cj$fsuR7adR-7=Z&;sG6WW%ODSu$PXJ{6^t~Dcn@!|X{w@o1b5B>? z@)AHiO#)w$_rNdk;U`y~gG0(~(p-eYAh$tsxJtQU@d3+ok%s zwKduMr+3E>=`o)HFL95vY#LWdG~=X}-a(cI5~F2HD&$bjm*d_yQ@bc9muxdh^GUUo-(?B1} zhKq?{eHIc|@aSCq%Ga(ESg5cQw!dml#R86RdWS{FK62h1fZt||SY)L_ZalzJgQwF^ zT#B8py7s1FLw}?B=5UtMQOlbw=e-^DF1Z6@iiQgiAxx!2El^Vd*tlE-z-yH9EG~@z z_8)gJ!5eiTL%COWt$_jfbzs-yR>XGh?AcJpHy*%}vrmTuAf7Sr;ss&$odh`k{@Xt; z0UT`&h%7_Igv&h%F)?OETthIu6;K>VV8W^;oxv$}RDh|!MZk}xOEX{-(*{6OfwIH= zm$=`MCzAfVjB}fgo%Aj&tWvh+7pa1z1>-fkjo?d1^!r<&bWk%7==!vG1=4Y^bZOn1 zUFMi75)$3!(^AP^v{A~s;9#~aiuVlMI=)}bnZOLEmpNI`M8|#x@ayt_B6Paw=>S7i zUqw?>aE&88Leq_J(|&X}^}`01Eh@UQ>+*r*NUaNHYHGz3PWFp~T_rE)&Vj+yMAgic zdE%`y^cU#*#)8PPKbEsLjs~2eGM*-@aW|`)j)>^93(uNqOZlcR>^1L_R#|Pz~?CUo`Yb9()Imz z>jl$1OnP>&t?AddMhgri~fmEiv@BVq{B7UKo?TNkZau3(%DA&(1%U*6iLX&fm$w8jT@%pb;p;l z^%d{i0iU(d!bqE~%Jjb8UNt%In~jF)piH|)rKN!~dDLt1lv?)-V5k6~Bl1+X6L6E3 zbs`#>kV$$>Mf1h}#^ffTEOMP{r4&>WcJ#j*$jN_Uj@exEla!T^XrniAn{rng4n>89 zqLN0l{c02dJ%gyLJJ4yV6;SeO%;6SiC#@eEgOGPxo-2DwoP!`bAij00L6HcyJ7Q0G z7=R80D7@{;L;wJCP-LDy1c(L2=qSqvlBlh^QI6ed*ppHpkUz) zWGd8i@d%_JF41RgnkSFWaj3>Jkq=ERcL_Z zz`O}=+9%4|j6qszsazN#B6Zuh00Z&`&H#NN)ymGi2Ntw9IK=E-2mc(r+-0#PTKnufWN%>qB+J}FSSo7`M`^!rF9)vu+hLqR(^GYJ675Li* z`T=uzrwKFA`;4~ugkyl(x5l#7ajX`&5ytrvAvAin-wS6p-Z9ef|6%RB1w|<)V3NME_0;7Ou z>^i^cyQkLp`(I@-llzLKPIGEC%@B5`yNMkcef-6o!fL~#GMA|T3x-8 zK!A2B{n+%YY#)tE8o%cUItlys$=E(0?oZYd9RHaK8?b18snnEB)0`ix6m^ud?>YhG zes5+K?`Qjcv!R5$TdttU$h3!r55`=!^Q|iJVd*^*8rQBpUHR;qz83TR`SL{qik7x+ z#hlPt|IrlW_l)4w4VIVCS-fD8zST%T*ZtS}i|WDbhqbx4crQ*Ln;&mpN?9P4J94Z~ zd6AhmiAy?1`v(BY_n^*u7j)wn9OvXSCP-cqreh6H0X0{r+x0F)KHt19Q#0c3UA4K& zZG|5OE1l#W&Yuqia_djyMIzG^5@#4N#6ES^4+hwA^qigt55Cf=4;Y_qpe|94*b87C z>-A^4E7Yjj&>*IE4p`FuY|!lqG&%p`a64KFrQGgrqe1=>jNeaGqG)$PzdyGjPO)># zE0vWa&Ynibcj9^@I?_9>OtgMp8-ykzv$9yl;DvZb4q_AK>s6+Et9i7H2^K)~) z*b3Wxqx9qZ?duyFxRxH~_xl0QTLdsBvMP@j^QC*4)9I^m3T`6<>x@FFH)&WM#Xa6+ zfz*~vadT)!3f)ZKWJ=Kx6@x=CaNTH8c^vHvw&TB*YrgH1-cFxL480)TxOvxUQ-?`xe`a;{>AI@Qa71L}K^U4__UB|Q+THgwe#DS&7<4ieUce1Y|6qUr4z(z) z8t+a($!I@{jaAd7nwLFW4<5|K@EUw8#StmHLo&`ZbwVRn4Nd*G3s|r~Zv6uEVYZf$ zph~KT-FA0z=%%y3dB`Ni^^%6e5y}$PkEGKuX!P@~PAX4tYHo z!jB!lHQkl1{OU76z~sj_liRm{6$~wzDX!ng$IEL4HYoxwk8MhzFL6$^_kdWqEjP7%e^YY&wa(Z()W1B7a zKu0?D(VUW8`WWfJ7~xaJcVBV9(8-ybpf1OelNH)I08M0J=6O zej&xmyQHbkBb6gM5~hmX3_hNk<+;ploUZEwK*x^_QBrRqVTbJTD6g^W(^5b^`Abyn z`>u)wHd3oCepUYwhyo>Ei?+qf^+`(}zqa(LZ1D$&`JIf6a2iF>JYKfhk5~EPqM65u)UN=iJ4*=p?v@p1L&qy;lecyDIKwlB6z= zwC%9}nMx`V=^Pukx(dQQZ{FrV6E_x*7_A?0i5Hn*OtdCifEapGGxWSifDOLA2cIVl zcg*d1DRbnMF$gh$C__Ib4;1@%`#q=1LYw>@6!r~ydz{{h|E>if{L=G=zE<{x8`+*-SfYKu0SiH*Q47x`Fb)(@qVyVyzhW)SY|_ z*d3eWZ`JnA5uM_hJv}2Zmg$fS={V5`ZN*&dX_s`uhu;I}%m`jR4<47x}wP}lM3yv%CSgJF4)l;Y*KFCz9NM>6pHLv5e0Vl1=LQtT1LcjX?khpl$>S%ra=X^V))iRi{ zi~vp2Ii&u^MYYn+TZ@dMrEPu==975_pi`Q9A0#!&jRRieM--J=80a$=9uFT3ArS?v z-yX>SX=h&N*`%0fxJnf$xSMgyl8>Y8#b{EvS7#>54!h7Z5B-qlewp#ufbH%;UgbYS zdFJL8HNl%&Qtmn6!=tP*Bv!Q{wOjQaJEV&B0LlGCSGZWuR6x@|W7{bgJT_|aac*?| zmLrhLCUkna*<_s=70N3N(5)jNboz1A$OgOaB>0m1xnP7mFF-f@6gk zb+9`Cm;1b7H&T@G!oAIIO~q$vlht{*CCr+=8GqKDgLm(+omD+w^HcjJ``y{67x`q| zZ`562dvH|fWbK;ygW88;TGFk;wvT1bWxek*Ja>3+^yjyi`f9~X)>mGVqT&=43kQtd zhg?iDN1H(U-Eyp~P|TxzzTV2t-d=k3*zx0evQ(-<@@tD9A_tw63H#W8&n*AiRt-LQ z!nWO_pO@vld;R)yomQE|cG+d0YskWmg^80b&_f<*ia9wfpKD7vjag|=Raq*(^ov}W z@cR2$4BGop1L%IlJd!By5Z3JA$i(E@peJl4!atThs^#Up`rT7C3@4`)Mog zP~=>UW?H8(CLX;2?pvpBatE)tEmI65vvlrZ+*o`&3ud`mumMa+bUfZ{`=0gRp2zOr zi=)f#qwrA*xOqKt3O|bTy=*+Q4L4WIQjvXdcA~YB8x!D{(|y4%^DsXv#sZ{*R`)T)c;TU5oN>0Ml(AYRmXLjkO&&)|?cVnA?^PTK=Dq;Dovc^ zER=u58)(C(y(;L^$=-J^1~CFBZ`>Pj`ttq(!xj4gG)X>|0%t6>IDc{uMl4nWMVg(d z8oTZVf2%hB)6qScwQmO(Z=uOO@XGjD%j)OGpVox4cqffu=M0Ic*Bf#(t{J!~o1VyR$8`k4f@zcfIEcyh)d1|q1yalR+<^X#`U#hJw3p@!t$!i0 zl@wfvQS{J#^r`e66CDa7IXNon+0Bj(U4fIM#)FnCgxBS2Lm6h?=QueB1 zzwF*vZw4fwJG?(n$GWD$N_>vnA+{4b0G^M;3Z(pKA-|EjTeR(X#Mcfw_K&$+jWtAF zP$6f-U4&Jd(Zx~PV1Cp9Zo2fx1&Oe30ZOx&t^?YR1Wy!q`swH z{*S<3ae;fU3Zq)L54|+AS>4X*u;&kx_7d?l9U~(i58uMR0z+qy$fh%<;EHDnHh)Rw z<;wT3$@XI(@k%V-{o>aMcHLn!@8Rf{O?}S4OnXz}T>E%m5(%}?cDb16TZTVOVvxV6p_b2O2>?*ZQ8u*^rEiu%;6^?s>*6v zllD$J6t!6ooplbEKj*6Z&zLgSSofRBb${yw`g0y{dGjn+zi`nA+eNwtg4(+x%Gv+_-hP*Zo24}h=>THpI)iwgKMJ$rftR| zjIHM*zb*IACmL-WDq^$6%{4FZOHw<|;6Y!EsdzrcIN8+{{^tg_5$5R)~IZ^zKeCG3@&|zWkB1vHKkL&@%tqstugP{;)N_ z=NIum!ty`|&&paWE!|FZV`rb|wLmP(Meyug)Qpns<{Wi&6Yl)l)+g8f;TPBoeO!8nS671sE`Fd)!9a()EmlE>XVpy#f;m~JY?fk*Q5e`$ zQ&Z#q;il{s%zS#2=Recn|Nd2lrI@zq6p0jkw+6c$V@DV;Jt1cZdkYnud?%9@rRecO z#Xc)uMJjZUpS=2ycGAZGI0c83vVduCpthbzQ|wrRqe8$o938t} zbc~Qc@o5K--SrRKW=yG%u{h7>N{6|;4Zvn@C-(mZ9xK3R+3*uC2&yWI+jE-T6LLzmtH=EVH_tr%q z&UwIBkLQ9tA*as?wV<6}50vCvGS*gZ)tvxo1ec0U+dNSMnldn&dT3btT|#WK68`O@ zXm+;8Hc$w;xw$3f<+TT4+*F836)?)@UB%%BfHINRXjo+40tX@v35v?CIM@CpqGD6Jb?4RPNKh-?rp&!u)4 z#xUKene!#aNoMzDEM{OW>D`xyq7A^A8uxoASBHAhc<%D$2i>>p#d7s!9OFRFiYUQ4 zYzLyo$Vf9-itmKSq|EacT`7g=DhC~;HQJw&Y4N7 zZKQS}XyNCvux8=A>o;68g*O!kd}2XDa9_U&jZwqv6baeo&$p*@n_o^FKg~UHTfbPU zGfn^I0q=>~dwrs!v?%kCcXf{A3vKI`jD_Sdbub7eV%k&c8#78?#ya^>J$uH6G4Y8!HB&^>7$`3(_>=+bnE~2f>;@LojczT>0u*LIohoZUB#VJA zvZ%N;NkZNHby`{jdMM&T=i}lu;}Tg5Rd+yP7Q8}n^yXS=Vb&f#WXlC&dl!vQxU(g5 zs{#z-8t(1bv*vJ95tiBmDuEISB0+y6>!N>IRbB3s)KppMQk(>h5P2k>5VEzpgn7P1 z795?HFio-D{Yxvkh60bUYuI?lHk*v~-ggf~wUK6I*G~+&ni91QKU%jG0RK0CCt>xtv+Oby_cM|6MHx2heZgb{>S*O?h zSk%%0W`LaigNFnzeIMTp?vp&z2qZ_P4RN zK7mzMgd?5{>qZ=;fCf#xHR0YoW~pRgU*LoJKCZkt8PS!9+bYYIyVe@}UE4Gm3+H)_ zZ{mI1A0N|6TAn9XpY!sn^E4^kCc3!kFIV_qg1xEvcCJV))>cQqn=oQP?IioTEX%n? z%5})O;g_@9D3p$?ZlVF#!S?kGY~UmRb_17sIeDVE&fOpSRA1slI44TJm2O57IAcyfA$;PaV3O}8fnQy(Q^|f~9g|k-CL|B9L(CV zNaiQXZ3!cak}@AgaLYVXruy70}vjlN@J>rV$6CFCOqZ;^Sb4zLPM4zzTd6=8e`$v(W?#6#vl!7$zDUDoL)jV`nlR<8C1bM^JdUAdc`g}*q)SGPJW*J7^Gg5mX;RNZ5dOx zN8E#bb#ecYP#J^P_3%C8@s>UF<9It;+wzA-25=cV5yCx)6nDC1b`n2z_DSOfP; zR^ADr^rQg?g-TT5V{Tc^!XMW(kPp!t#+84*K|Z58gfiY;l9MCpguoE1Z@ul=7KkRe zPD@@&hHrB5f$~59?Vn%WJe1XGGVU`}v$=FV&q=y)p#0+JAHAuoLHIRXrX{T{RjA*a zq}f(R`(ZxVN#KTMgQsi;O}r%5`oiN=HBB*c6pi@~Lyzvs3}c!nO2~IoLk<%IwX?l@ z@7G`2a>o}~-1G03y?L+O<7c+v8ef zt0WD?6A}q0WuS3JD<8B0tny40;t+mvdsoYbLlhgB+-|rA+mu09C<)wk+*9Drts6PK zjJr}`8ypFKdZ!89h)%G3q-`1m{i$oH& zbBX1PDnua{zVA6t+xO-=8i#vioC)Mz6Dt@RSY2J!h^{gIp}f4j@<2(+UN3Aw)7o_8 z^}{*e@>u%4SV>cbGtooLw~KhgHA%4hZNRGL;Q~-GCMlSBYiLl}WJXWMF!n|-kI6OX zFCo*Tt-t``S!M)xq*SwS6Z>Jg?x_k?^MXi@yUe!z3pzeajdGOPvng(l*Gh`SBq(Or zv*ltI`+b(IxYiIBJ#ahsXzX`sI4Km-V%5$#0Qe)u^nF2eS1(}k8T6x9UqqURDs_~u zPT>l!WK^XX7rTBfU(+97`e^wY^cvjrqoeue;aS|2x`%ICDqm7!_8@8Dw-D4NriNcc z$pdDOKPbux+?9z-Fmsplna;j1PfE*u9Fkj>4`P^6uV4RMV#pNt!ut#`77H68tXfer z4e~$5I#=x&gXdLU{UowEmGNbU@I|2U43GyDEiFT7wii2uX11^b|P@2qU^~< zeM^p7y9%2kpDN_V-Aws#9)y(A_ko!NN0ktE1UDYQ&Y1Fg zc9qnccppRPvKJ-8to|!AEJ};3rs2rgPZR(;>?^cmqIAm)$Z)je<^dj5fzk6ChJ43?2~ZyK!uy-CJ!q2g{$s|W0?;WT?5a%xe>&E{@%qt%($f3g4ux&EIT?<*js5P( zu|^-obYs2~rf&wET3_#?PoCQo9!{VBne2g0+MRQ^rcxJ$vS6$<4{WtsX$uUju}qE& zSrqw#wppbT`Iua%HWuf8Y)XoA?lc*u$_-%gW6$-b53^1umjY!ck|`5@0TBQ|QR|L0{XK=6hP~IG ztbnV~!?n8_G$y5Fwd8A9{M!3?OP0S+$Ki4*QP_kuz zNP9A!1i|avty|gSpKpeqR9J{vyR?HLv}AT{qiav~04o>I^{(094u_l&MP@8qEQi{d z*~l=hzPGY-9lhAiZu$N<9d*c~qnL%F>Cn%Zm?(AE$((THWL}K1#aM$L_t16H5~=Y1 zfCUV=m``YQ)UfM34q3QUC#bXAE_2>KLH-mf+%2GT;PvaDGIMMnkaHk`Arhg2{k$+! zmKbMJ>S^HYrdVd(y3KNV*1XJgLRRdBgQJwe;n|o6W+3lLpJ5u?sKhnC2Q~toz;zQr z?VpXf!qz|KwG4L=%}?f)wmrB@kWc#^qCP?9FBK3SACbal_9J$bPv_J(Y}zJZ3QMj) zk=M6D(mM*vEowvd+d+zrf1`N4e|3^tyC3DOZH&;%yquu~vm+sqJ4!d+|Mq6cIemUu zaJ`EoY{4DCE&T{IQkcBQD8=v1_cRY%e%$h!rHX@ty14dAte~rEtA(4uex)_iS-b*I z9RFMMg)gneBsu*zwp%bdJF+*-B@Rlk&{wIs;pl28sx#InbZ2J)*?$taerfte&SoYB z@(w+Tvdg&2#oqbjP>FO}$;`C8y1z)eAvu#d>iE3e(2leyM z#N^knle_(v+J;x{9)sX^u8P*Cz5_~IduVJ&?Doqb7U@kM=otjHuNt~c#3ptI^PY1h z_tZ5##GWlRt@Lr%KXc|4$JI=u6)$IMuyR=q&*rigYhMBa;p**v6{oUw9&JHW+(Iq6Pc#qFMDR%xQ!oF2DihEyZeUd#iM*bLCG_CB6~WTjS7 zN8P=FLo@~mnEbXO3|Bdat`(<*hxxtni+#x1=zrID`#ssN7(1+OYHLTbNAv>Q!EJfg z>BIugwpO8FHn6*m-uKhLPxR$?Ov4Rj2^_Lp;pl+PB5SzOspKC_lzXsKsl6aHD!=n&9iDqPIHU_R0i$8t+BE>c5sKuJta2lr{RI`Z4;h2Ei9j&-lxj zL5TyBBZ^}!Y8Z2hoUCl*AvMlLwf7lqcUdXX%$hj2hKJ~b`h}~f` I8>w8HZJ%8{ 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%0A?e_gvODZ_+k4NRJ)7Qk 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

6Ko#y6!EgZZm;Qmv|Pi*?3jAALq~R$YiG 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 *u!cOKm%gJ%c>nU+VozJvKADwr(P!NEZw( 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

CF(PMJDWRG}kymn!S#sct5`Sw|yf_LX2~ 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%0AEqi_gJAZ_@pcTtEQy<{Jt0sx2sFK9BIw$44 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 literal 0 HcmV?d00001 diff --git a/docs/mkdocs/assets/imgs/graph_architecture.png b/docs/mkdocs/assets/imgs/graph_architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..903d993dd90e0d838e9156434814b94c76dffcd7 GIT binary patch literal 145621 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
J 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

r$H{ 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�*Ax5tbb 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 O0>9=fE2aiRx87{PfcULPvq0BNrSVY1gpZY( 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�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�LVE9WTqaj#TO7Oc57*5Ahw*0)3*^biFhqagh1_m zb1v)76UXr2yg!7y1s~Bn;k1ZREdXv$9KQh+?+0tD=fh=4bby4FQ*W%BQ01(MKkWnW9csw$w7bKT z=25#W!_WcVS3O*{j`5C3N~M%U0SeV%3^trVR)XS*0gk52%6oWj7wUi?c^~*O=Yf&u zk?mLF7&WrIQDNN_9xeZ;&0Dkg7^^fc;hZJ^gd=|a%sC$4f~+4k{9d)Z+eI5ptR|hi zrbd`F^#|^ajMOlA{z?lGr7r{_4q=ng`LY+K0gz>qy#4bs-gHKy3dneu67tgbCL(AP zPR7||E;(j;MV}NeqvcfO@SXlnXl^gT6YtQ?Q^sjIJq4HSPpT&RYO zeJil|)e@ed6&YQMk1EMl$J&;mVJ$AyZW2|V&o>t zu+$=?iJV-Ulr@1o0yWsjc2(+=k8`ccv~*VHvfgjnj2X6;16TrH&BAXJm?-(oX@w#}Oer83c4 z1o8{-GFCy3rR;WF97nUvS%Z{X90j=&xs1Y{l6RrBkXSB7hzx!%v_a!O4;^Lx*T9!=_RJPzXW!GWtXf z_c8GTHWAiR({%tHS=lho*iaz|&yM z-|FJI^Btqdy7!yKpw|of3j4xY&%J)v!4G_H_4eg(2QYlVAy!E>x&O3Asa3$VL8}xJ z`Siy97hgIIv+&a7w~u8L+YmQ}r9()j$36jVw~AdmSCm*bqbTMPw#w+AFKz}XtJ1L8 z=JP0ra+poK5fd5P55Ka=5SRf@qm3r2k9nMjS9tO|$uU)1#xATc_^Qc=czH-mbx*0c zYPhGUttauaRpF?F`bysrg^(U&ccSz+pNL^3C@~zd01DS8$!8P4TVN!|yiWAPw~(1u zZk}c!l#!;SF&A-|JGz-+fQjwdhq57%LZ?hu& z`>8@cO`@uYxdi2A&W(C9aqw`ao109#E41QCF)`RGPn+6fl){+U;izTVkwGFh%s_f) zv=x?{?<%XItGc;~5Y4cSRYfu*X&~oG*8XG*tC46s3v88yx(DL}>l)yDK9+HNGMXPj zbx0!t_omp2oDee(u%=nl#!RQ3vA$ufDN`$r#<8iUB_+|VM786^1U~tb-~u3>Pg}le za;EY8Ac9Up=nZwQc%^ZOEl3P8GATroaPO*kTsz(vvo7sN5yUz~e?rL*c*4MoC24pQ z48-qWyGtn5{o72)6m2#lidjUz2Wl51^_OAWS-NZy4dyWtuE2OqE0+dQ^kgPk zFDz=SMlGXd$~37_)9~WbHQS8~Hb!d?_{C*DnvE>Z3M>rM zjnZuen5D)bd|bxN#PI1DpwhVQ)$iXiP9wTW*DNtzMG!nGaR|oldS&r1Mbpc)>5>js z)4zh%8>LT0IfO4=TC(}g)pjOM%9U_1j z_P0cNa3uiP65@6_j7}de|qXQteRRMNxR@{BOH13*B0Y~y;OS%$Vd*6uh{F=L9E zUTK@a&1Zx{gz1&qHqpUe(S$KtM&`B-$G-#_UY;t4Y4u|_Avx~&O)9y|@sI7n8Br5O zBIe3-EdMI2=c}eyuW_xd35vF%p(sI2tI2L-n8{9J&Dt4<35krP@bRBa`KWx#@*h|k zuiWD#vc}AYfo85{zsQQku^L|1>~7O)MmJtMheJ z0#z_z_tYv&{>0PO0a&+!)y~~1&ZROn5>*lb7%hOvd)}uzKS-rkPTS5+v;5d|yIT4{ z?G*R6WN%hn(N>k~~m*#O}H z)8aK7%`Q;`Ix6Gl(zr0HUHZ5AvoUErLHuD(_?NtPGXP9?@c@&>6lYXXR3q>vB{9P3 z7M1m#I-AT!2~%+j{E58GBp3&Sp;}L7);1~1H@?B=nB$bl=`k+DBu(LJYyoS0A*$>S zaYkEN@aA23Yq#fJThB{Vc9AHIZg=_O&KYLwc(lXw(+#D^+OWs6?T&LYQTmizcB=Qm z2%WAeEmGoR%4TUbp7Bm7;%L3AJ1w-dl-{;MSZraEN*Iq5F&p@shwJ!x|0@ZP%XZIs zpT6VE6S>m8t`mAxqBFF{1NRDWZ*k^oOFKU#6dY*|P5f@OQFtYd|IkDQ3*uRw!E>sI z=NhSw5QC`n{VDZajR>pUAp`nSzEE2(vzR5(63X1QzWM85BWb_ROGw{Ohd*^KURS_> z$T1$VV5)!x6OoPJB-O;8ugI)&*!pgydqg~&Myzs>7w3`fSl$uXVAzr%wPP4Z>c$J0 zlD)n+y$(y*oI|Bj-fJ__x0l@OpE`^=BKpKnbPDb?9 zNsdHMAT#1|d!acGT7*?tPebk@cL|qC@(AVJv=~0sL^^)hkF)(d(t)t z0>>du@hWRzX8kQiMU=U-PBJ<~A615LeK?MCL+~}>-2hDXal}KY`N1+z!b3+^wVi&J zHvqdJo^%o&BWQ}qbbRxnmLR#(j~HuB>9c;D-~x(stP+8nCZ2V*uO9vH0ZKwCg#Wq^ zQ+$7f;dp^?5UIq^M;}+iV{}ny9(4i2jw8yh7a7qwHzN279m>dicH|rN>@yc!e=y#@ zDl5mf=r#Yum819QjZDj}c*NH_-tSz_#Um@WXSih*pK_1yN@w7;JPSkwO8dahW0Va> z2OuH3FXgD;@oo?yto~1bAcB>F6PLcO12CtNz`+Gz?T?ATF#7>ykwI;#jZlkjF21b3 zk+J!ngYp|#E_Wi{jtI+Vb^5MsC=wbEq9Xn~E92|mPW`K)JqH03vrwH%`=vtgcFmWv zT2;gW>VFIBD4vC}Yzow=x=^KHk+cfPX3Q`PKKOO@q)~n(GU2~3VkYwzI2yBX6|zy* z%%Yec=-g9ah;f2?G8@AeIS4%{Kk#|tk}#QDFL~Lt z`?V*qY#=2eBz@09W+B2Lv6FQlm*dj|$6m>QDZJ7@CCPsCZ5ctr_CTO?yByH8pn&J* zZ{L8RXrSa626E^?R&)w+Hvn5Qj?p|IQz72*nERly&_5%E5kUMmr&Y0AQC9=>fso{5}wkbkaMG-z1SZ244fV@gCJ8Hf(uokvRk#x8kxM5V?8Ec65=Kl}?Qkm;ko z-Ai^pob`E)4Pe@PVKPaLMGe&^Z;VEF0b%n-+~taRH7*j-s!kQ^1P=M(*yAOhnclr; z!mrRTz3uoee`n<(zZ1I5PEnS%DbQe(UhxV0PW6*rF~DG z-7uTb@sVDJVhdNnW<*SNZ1ni4%Q)BSQD9-#0n5q(vN$k?o0GUpE+3C`cm1_|B=HjwXyF7x#%n{7DVGwI_L0W|M zR{!H*<^t+Ix1h#he)&S5{5&}E1O#GVX^7#M%C8v28w{4pWBgKa|G~BA^Z00~v>;36 z9q4WX4i0ywX%XA~o=3;`58yO-EJXyVG&(Lyrqa2FQ96yo+?e0M-s!sM)cBUnbxw6X zF%^}m+xR&2&}~&AR7B{5$aovto|)wwJ*E?1K;%U8eE);3t2CKG>^Z=k^m>UNWEBI7M;l77}JTLN-SuOp@Dq=?sSQDrvb28 zR`|63G|AbJ+SG6{JvB*gVdv2|c|&2L*L61oWi?iL=bd`z2MVS|W2ip@Ld3-M)89a4 zRY-(5wF|sXF(5c z8Ytn@97(Z`h=^sK){96QlwN-3t=SB2fwu%lGT8#N{@l-W(x58~pjw)e;Cz24SGObC z9rURu3suGV78wF8Omkg|Z2p-(H%8KbHRlGIini;mC2cL$c%S8q^KE9!3Qn?WIU8Ln zPG!YWWl7SVGt@FPXI~Wo3R zIoy-rwDA9~mNOH7*GKGDrZ)Q)@7uA#b#@NB`<;e?{?%%QHrz2y@-$g@gra}C{4+Nu z!o|YFf-M^?e^_?Wbu5eJ=QW(~Lex!;oIz!>x#Bv~C(&VgwUfUa;oBRG09=)e^F^L^;T z*J6??rMoLW&P`v>j6Kbw5qWprusw-`&8qa%=fWGfB>$1t(fGx z5Apc9PoJ)n#YVmn5Z{p-wceZPk6*X%40e3A8--S^^a1qBpzS?@pB_z>dmM%TrZ!QO~tnxsFkN@KTf^) z)Jexqe2Tk&H;8TM#s>~<#z=JrG&UQ5E7w&52tu73rMC=4b-eL^K~z?0=7B7s59>y7 zy?vS>;r5~SD^Y{t-=)M#K*f zueI;&=>-kRE7I{lDE^Y0g%fhm7US-R{>1;HSM|6BXMXbe>Ql+Ar%zG;3SZON8vP;j z6;_p7Z|Vs5Jxanmb#LEY-f&mXL_W#(^ByxRFaFxoq}|^w6na7)8gZ83syU=1SdMsg za7=s5SpbS2;C#%Dg!qxA|AOZ?zqu$zW0Sn_;X#k)mjPGPl;|WwRW|WY`2;px^FU6YG;h(ED6X8_N@K)h^1aaw}fJC-_futtpkYGSjqhV;l{g>nFdtz~# zOPfB6-#&fXlU|@f`QcY)*MmWveH1*n1ikA6H{dS8QaXbqM^8^ayrfS7mJJ5!09Y5L zM_)2fJgxA1#BMT@K|lS2^!ywgvZivtOfI?+T#zV33FAETnzd%CmQ%$g^!0mRSKAG6 z)nhk`OTo|}+G>A_Y4T3Pp2g0xM+jT}ua_QCX_o#c0}mFhXyM)Lk#3iCrullB#qp3= zRn_pq@ErH*hywghzN$b*d4H0&Bjm7|sU8gg{L50XmS{EkZ07NPq#ZQ#?1<0%S^?Jq zRsniA8yEwTf8S}BZH8v$?X( zuQpJg6@50B?ftkY{+cZgraL(AV7DWo{b8 zA+c09L^KZ9Vw58}d`5V1Gvl{ETdX^R=s~mQ#}k))EvjXhR7h2=-!%y2g;gPXNo0hv zn7Zp~{YL6P;D>N)NtfzQ+gq?8Xm2~TjTbpCGiv{!X3fHPGRn>@4qhoS1elSVe!aX9${AHS&-{(dyLG>9(2 zqN4jsNXCxtm77lZCriQarZi{VvY_(rxo%z3SO#xjX64?j&ump{bzMt z7i{0FntBz4VS^Mk(>J4PUU_neTfMBE)g=<9WXe#m z%P4I1tJ}njRK@1VgzR=%9h3bY=)HeOWbn50`eEhkAd$?#jZQDsZ@X`mCa)%a-~S;y zt>1KZv4r85DL0qjOo|Ph0Mx}Vt4#MV2S?Dz#I~yosKh%jAIF)y)=Wm*Jb(7+$tm;q z1$pTe(|HC{$!C6~dxA4e2gx4zIj~QEewM`~vQarP%xjj@55^k_>y96^Yb7OV;x6S3 zy4VPpd$!VPx9(Nyq@6KzH`O@Y`+k_DJhT$0^!zi*qFr_ShSy64d(l=MH)k^S6O5a zNg1Wr6{gGakR6sxXxN_>Dem^xYtWjPliPx}Gr03^N8Pm+RU%iS;cOpVP! zSUfETt{vVA0$XqpwFlgGb%qs~Tub1ut>)&kt(voTN0j5lV7OIB7(ff1JFnCni&RN% zLBM8H_}C$CS*Q38ZN(6Gd9H%J`_q8~BB=En;T)fKy7BeS~+8IkAGQG$ovd$T&7>)Q~k%~&YyxlHAm~OZrxD za`??hs{QNFATsuJ{g=G@+jakxv*j9WLJiP|Q_)gA{0o3pM_onN8+hw!Os_re3lwPQ zmWhM2fvDl_jS2mhim^fOk1OFx3@3fOh8pg4Y0I;#D>6MMUB2wpq2XX4Pd4T}-);%jX8pMJ76 zbGuV}E9YB4zi#upt>?{=A z#gZ~(;VRGZmD>4Pxy(A`sGs~uvu&@2?<}%R&K9cXI28FTn1$K1-lBG#_;k&F(d5dp zt3P*<>i(v0;S51oA^ufyI?Lev0kREGeiAA()`QQ-3@*6H-PY3%*)q#qtlQcny47nT zPxBnOS9{;51*f}&%eybamT?9ahP6Lqc{ zjg3RDyCdJybwC?^TRcI|ksV|k3apZ^!7PpBZq+^^ zon-+rGgsnNkvI9R6OA>luf&m}{Uk9I3GYj%ljPg2hARQK>&QVER_hH&23sl6(qfRJ z2`LNJFV@2UXp#Bi%AkK{_Ms;L-|i2Otjq@2an^H`W7~ zib2lFQXriD9@Bf{FYw)-izDo_O6a-~k^Q?+m1KCPil}14B)FRzsIw1(n@E@<;Q*FW zfCAnI-m1yUY3FL7jG5l<1?k_fNf=lk1O4pUy^p`)vcs`T$cEBg4FL@9neh<+3(!l*Vtx`xu zzNG`VkFS(+7I_r>o)A?X4R1=}OwVSDOOK-g$x=jWMw`diA5j2*AW)hR7=OOL(8V-^ zuN%8)-CFZW;@3b!EB)&8CF+`E*6B=`K~6q0bEhzKe|y~*O7&(aF&Vx=HR$>E_U9-X znGtRzt8&2H95k4dpuC_#^3F|w>(c@sXS`K+PM#*>LWC{!ICIT_N7Efs+L&&1(yfv#^l1qKKfE{+c_mvwNKiLqpYI8z=P z2DZt2>c{SeoUcg_)b;%^BWI3ENS?eutwf%{PS`$+E6cL%f?wZqnb@BGY`o_|8&pIX z1RA-Fh4#=K>Mf}$lvzl{CerBWy|s!zfZr-2{BAzq26Pw69ru63d0ew|IG9$Cfgwa( zDReH8P>;wxd6+0OhV6znXtFTFof6W$Z%DJVx!2f?_@1euyIW#?WC)h* zZtmgm?M6bf)*7T%76(hHQYgHmVbCJp?(@+|yKRIqEqHi$`IV%?n8ZL*tX>3lB6S!1 z9p*dcTFIP>@5>&veV5vGSZ6+G8%Zv%q(v^=qn1mHO6XFW_~j>r^!OKsavS!)eE)kK z^1B;6;o>_;e%&a3DZ_vIG4XraJcI4Zf_@1a5Z}TOvj5ko*TJVt-h4P^13Wld;Ama0 z0WY9w{fH#31-XaR+SOU4k0X)BDf8Cgxq;a3)1S>HUO_gZa{Xbh1yQ9p(9)s{l9kS~ zM#0C#N+4I%%hBGnP<7c$!Fj%ELVqx*S9|y2+4lw`B`_4>Yqz+ zeiSMmitoxuDi1lg5F^bSO_BOG8W)a7AC4Q|IFEuuD810RUu_RGOthv6cV1m{czgo3 z0M#TIC(yWn0ovv5ZClm~zPvk}KsTM;5ydwFG;wyUAD<`5x>bHMwt$x>3- z!139@)zHRp`a1<`Pr^(tsa-|PzsnQfZ2;03FQBQNPnr?kC0Z*yZ*VdVL8&g*q&TwR zFYme`pDuZVF#h~bNtAg#jGX<4k~C+vD#mYiMZw(RZ17p-ox%Uq-nBM0b%f!Q5HNtj zAUA_XlW>b5HIlS~Sm$tw1_?+hD5Wr>pc4#UrsIWMD@sIKr9&|c-Q9e#KcLeu$;UI#c{k_XXLsM{IeX6O#h_%F?zPI> zl{@KW=$NgFywzY!KXkWzB=5l#U|ud1x616=#WjHfQekhp zcsTL-&HmHzKVP_3&Ir2_xj$r;9sSaMZmzn2AqRM|rzA(qylP6noVU*CvuZs4Q6Awf zkpJnArV_wT&hP4M=$TcGP_s@e(6@ZL*Rbyg23mHi57K{a7osQVm(0!p+xM`+b;?h(8e(?DnHerWkc=MDX!I}4kb zq>T*d(?v2q4=u*$(Xy}EH|1(wD%vCy1sXesrvGqR9kkRr`_IXE-q=l@_O7Rh-F0YPBA2K<01)#=v7^D{9Yl!bF=bJCN zawsy9c1Jarad313=0{}G?aoB`xgn~glU^{9qB=B@`$j6NQ;0U19$20C%o!`@nM`Zo zu}TNgDLr@IRAUrHiG=;R!sXA#Hxr;9%j|2CVen5vi?Rz?l=`7)t5=-ml9B)lvLNp2 z>e6IN-0KaS*Dhs`Xrxz)P21{U%5!kUa}UH-`bbOSg+=gG1j7Rs5gU3nTqZ-<&b?8&E61(#6QPX}gD zV5QQmnVf4&HeZonu(A+8hoY|XhaOW43dqoV2)PGxpye&!lLiE%m|a%kp?s7p7s`cM zW{e}stbEW{NfTPB8cV^KDi%=00U9v~b=Pk{$&wg)XM|JzDdT8^j2<&UuTABtcDwbV?XRi}v3i!VJY&gW#I&D5( zh<}W5^&=j`XW=B}JYQ4iXZB&09qBg2b~NzB{8$nJZ2N=h@lrJ1Uxd&^d&Qk!`KVs+ z?ecIZzc~P-K1ks`q7cZXFzfWVeCVkarTDYE%|3T9?it5J>>uYuY$duN^0%_vSoH&7 zmY1`$tBlbwBK;Dx!U^G`Pw1?PQ3xmYJ@5Q>7~o>tK0T%eEdT<^2bDDPH7OjoYL^H{ z5_oYjOiyi~B5?>7o*Djud&F*LR*opr8TxG~>bdc(-6RGBWj)41O6Xl!NST~$A!Sms zWu~;ul$M#&vV2>XZ%a*SsVOZrB`C2iLg#-FI{x>0KW>-@&uU7p&M_*s5TdY!;e@P+ z#?caN#-r9JVr!5iysf}1=TUA?slFfs4G;F_kr)_({OtkEQ1OK8aqQiL69+LvjNi#U zMP>|w<9T4C%kvMpEMP>y6U_(P!9mTmiu>-_|4GPHxk#`x`(KS?q!+Bkn^W`M4QX_0 P9R_|8{OE9XXsYxddPAH? literal 0 HcmV?d00001 diff --git a/docs/mkdocs/assets/imgs/local0.png b/docs/mkdocs/assets/imgs/local0.png new file mode 100644 index 0000000000000000000000000000000000000000..f73b2429ee90e0710991beb85f155e189e13d589 GIT binary patch literal 169043 zcmeFZby$_{*6s}mQYwgqAV_zqfPi#&cMC|DbeE)nbjL)xyIZO7OB#aEoQ2;bSi#8!%KI;qAEUNsooFGttno!wu%K3JTs9#>MH zNPIG;>V-}w%RgZ2Ss`BH5sOCSJg@$}Di=~F(P-*`$w0aEX34G0aMc^%r@}Nr({RPg zXz+Hjd8UzGOGP!5m4G8?0N1FcEmkLrX;r_cXSNn^ACp485(rsZ?h%)G;$1NxF z@4lxp-Qz|;POa|aw8Un9{OV)4tWul4CR9)46Rs#1Rv&otHA%)-&n06!4{o>LA-}sp zVUyi*E=CU_nEP(tRx7Vijms+-zB;$zNzOY}*ltl0zscZYU9c9(f1o-6VL2NQLtbsZWSZp@PU9 z1Pxdk8JAd5`WigaD)!nFbk(WK+*ldA)7+^1<4^N+RAiLUc>;vt!{(hO3`G#q2Upz} z-W*)^e<6#DBSBr{sB5)vnrU=)U+CXG*=5^E^vKT6W={!&LJa*ok6#$#1cylTY*msl zB;0@N5f9}f_ZAk70Qzq{JgFg2J|ook2&jMOcX>R80>g zGp`X?URhb&DQb%5^Lr6Bq@X#er;w*8aecN!PCmBKU$x-otc^dA^><>Ua9E4c*rf8- z_>Evqu8K7xt&${+*AvpUQI)B3o2*+00*PHTlCkBTuEl0%ku_tCc8Ez#e!lorrBNA8 z60_C-30V}&`u*KauLty#(|Xhe&dWtFG6B}5dI7{`MXP-8wL3VS$+2@U$y^u01>TFlc3g}kM;>-uT z{;Ha1USGa9tU{$29L~L_h7(V|y};f*&>x}7v@Ps1_cT6{qmvA}<|Cp45fzQn_q?cF zezCk@JUDAyR_W3u<(1{-WBl}cpADV0o+wf#rZJgL7({hr*l2n!y2o?e7t|+awxv%0 z=VYgj35$dwNlc|E3uBn>4G$-(SSj(5EIEdNG*}ZuMbi}8QKz?W2vxp}f?R&eZ9VbJ z2AuE+)e_oUOq!@X`6#3)>L?0|)lTn>^mPAz{gD<*c4@Aw)mI^dHTTy$89ZK3OWp3A zRI1FA+W0$#uaMUC9`5hVJ4dr+_@>K^h%1c8!uMvXdWSQ_=FUyV^WzB0sn&Be8)|J1 z<~1(75bO>Yd9vQ0ZI4+ybhnsbjh7qA<;Z3Pq%DjWsNw0Eq-eMLqTwb!OH19%N4y0lCGkr zdn%KOLfUeAoQ5^6RetkNqXlY8MR*50qd9V=dcByG0Y+JpNvGG;^4SFBl8L`Jx<;m& ziCwos&SFnIN_d)is?m+-`wk8qPkaV_K0Wxfd?x<$qDP{70y#oK>R4N)xHsJsvC=6l zox$RG$6cB}S<0XBE^3@gd|zu@KKf^<3-oVD1o^>R;jlAN{$~n3#uI&29f zML%3LKi-zk-#xj_PWqr1z`d$g`sMo4%_-ZUZ&;gzBq=pv=C0Kz2ytnyX!N( z+sh+K7&L|1Qp!-@eK#H`|Z3U8*`P3`;Ecm7;us?x~h<*)-M*^wR&Rc4?giD9}aR{B+4}u z-Mzo`!*}}{|AhC}tZHsu-oV@?ZXx+(9XT1<@xK4wk@UYOzW?JD&ta0oy420%NC;2! z@En9Z$H?dd7p=G?+~jJq#PaHA#lrg1n;=HXo(Q`kQ3UzvEqalyQW{z{h_HY3_1d?! zt%j_%h%`zoInCVJjCFq*>;9^>TK489HHY99Od92%0%8bR6$)+&_8+xdVD~T(-y095 z3ZT7tLyh;z=IeyQ1W%|uZz<+;!tj>5VzC&y=_gj;;`s2TD->q=vx7r5H>1x65p1=c z*^E)j;r-2lJ(}c3UtC&)?$OUT=G8o07X9%|l7zk}cvMrN9oK|Dw@(IVa^$j%WiXcy z1EQ!`SGyx%P$r^j)r>u-KjFHDsbRXt+}F$R+SS?YOiKj*wrDtuKHZrZoYp`t?OJo% zpJi34cPKeu^dT=IKk;kIs+k>I(O9Bc)M(DbG{|u*TUULZ^wBo{bbn;VXwv^BvCW(F zy8Rg2Qi}!79F?+6PCnH48vPUZuOChZxlHa^5-xeTwby%Z?-QA|u09vU9AD7~G0prj zUly{%y!9Eu>>)1JocmOI{rkO$C>vdW3vs&R$uwGwv!+g!S@fN4YWhGSy2LKGGRAk- zud=m_dxS%4Eg9`8cj1v?bk5YPB7RP`tD}u~AGnyBEG$v^+PqQWn_m#xYok^y$^?9^ zN5-PF5l2DcJr$@M^Fo2-*v~s=r6p6IZjA^9W>&jzrudZ$`yR6$)Ds`R6_}hiNOo0x z>vG5=&o&jpbx%=Le;iQAH!udCD|6LZZw!OJru?j$^>`E59qPV+UKZ5&W#eLJw+Uw_qJJ)G2!57E)K(uX%bXF}HyfCWGj;ZjLO;hCtRFmYrwE-?3N-5MuI;*|#PY{J z=yp8Yli8NfF%OEhzdkkU?(ZK<)lT|k3ybd!=i5GUuOU2&(R8QV4HGZ6m(Fv%t7No3OQb>BNpR|rX-+PWRwRjty*Xaj(-r8@*IR|bW7?gKDeX|-zlUTL zUTMEHo=U>-YpT1N9a9mILrCs=f`g5nbF!AoRWDNTbO>ua{k!$1{^$}%U3}4dj*2q#&1dMXi{hePExQCGmGamj+WQACYbi;lWjrw%Sw|T2*tXu*Xt$NueR0EfQr-0aTwR9INT#G_Y!@N& zOod5|i9Ib%-MaHxjJszya*jvr{pCu?PH6vrZ5)H{v|_$8;ash41V{!dPN$nG7cVS_ z9LD0^*0)XE83txdj0O*=@$+wZ6WuMYit+!P4blBPgq9e^95o=UuA(i2Sz^-0wcb9k zqIZU*{#4M613@UV>Ca1{h27rK~K5CK(cd+-K^e@ zA-RNS)b?=|VQ(}iV&4Ahc)j^j2 zW-J=-P2Eh13yHBy(6d_zB#WdL?C9vIJ;UM0Cr;GY*Aw*#MKZs^4jZTqu!)MOkQFA~ z>o@L4G1{|cjY7d?S)6=v?2?&|4)MZ-p}1BkP#t?efG*xRSYFO3M>@qi^~u5zh*3P- zOzA>(RyiAWu>`K;k*C=Vj`z2YHFxJTc!L7aj;aNzMHQCA=_22x%JlnBs|S)k?YI(7 z?0thn&eyP=nz)A}qex9HU%!ZvdfBVT@aN5={sER9DN8Lf#*lUGJ$5XFQhWOqE-FPF zwh>x_9dDsXHYr=BX#|e#v9;SbX1@U&0k&{8>Jf}Q0k%>YVkpZ5#Uz`W>n1l3T{Nr`P+P)H@F$f2uz`U_}$SQ%b%t zNRf*8qH&E_S*_V929p?k$$gzqC|e>(i>N^8OdNSZ$Vrj*!mL~Li@<_BMk%vclvvB+ z5Q2r!B$k#7;m&x0^|i}!H+k)I9Yv6f@b07yXVXh;Hh=R>EG5gN39|N#!?cOK<}IxcHkQrE*4Yo$Gfrjy!~=R}&gnb1=ML0(04vm_$ueUWOO& zrcPU%g-3)D3UK~n&km(#C(`bcr~y?Z3v5UKSDw#Sm~HeCkam0`>79Iw5Z1m zHK^z6>|;y{YXsNb*p$#B(9X~wrMnkA%JrwDg*!r`m8->%X48y!y9@>chEk&5e43y@ z&~56Crj3xxk~D&>bo_|ma6aGMz>yMgh*h2Hi)RvhdW!#ZLvVA&Y^F*?T|Lp^c%R1X zJ`jsO8v4mo!;qmtt_SrE<^s8_V7^d$_?H_SjW_!dt$rvb7OMWog0Y=!uqb&~(+@Qp zkP*%L*eCM?#fOJq@90i{TpO(S#g$sEiMsCW-0Hvh$LZ+1ZPaW~2`REJ<@)objvm9E z@-{1$o#HMrJ$V4j|C^T=Ns*48NWqM-tO-+S+0pYec)ignYI)34Pi=AO>jPGL+l=Ig zUo)?sLia}OaTRMZt279}KgS_at27-VG+HV_S~@Em9|w-Ph}Q(UuZeI-UPVh+}&K* zZwDq2^7$E>PZIMI@G-Vueu*N-TCc^@y4*o@xrARXm_p(2Q7lk38bbfJx89#{CSJJH z{HcTsVFTgGa|^ZEgPAHRi2MB&jMPO1*LuEIQ{z4NqMx>#oL}C{%%Sz2+t*J{*6Xi& zHh3Wqw;G39eFP_!@l)!VL!^> zu!>qrj`YLR?Agq)aMF!eA$Ri?L6Jc*Yfn(ve{!JA(dOChO{`IFyAb&YyoA z=qR8I{>nm6*AqacHRT(xjL~!wNzE1L9ye2it6`fdH(Kd3Vzu8_H&KeFe+hHQFHk6} z^=v67@EGk(kMsP`H7Yb(^)13BNus9ljGuCVdPbLlQ3aqH-I0`X`3f{zM6jV7c4N5; zoCVRTIa1Y=e`HV|u!X`foIcwP=!k>{x{uIAwZa%?$P9$_nB|Yv`hVUik8KW5SrX~= zuc{@Y>22fwF&r}=qnAIhF#WCN0xPD@4&y1n@rjmHrLf^j(b1~K#%O8@MZng8?Qg$> z73?=6A|g#1fVpbcDzL4yvv)$vo!0LKD??42R-ce zL=ioZf{~k>h8(8t0T{=i^t$}et~X{6BD`#wzC;#B!`Zl6czpb}ynTtq<(*y00#vfp zUzT9fiU_cEYQk3mQ5p6R%^$3LnpXRAB7*67nhC8WefEC@1Y3@31hsgfa8iIGDhf~? zVQy}Z)wQ+m@2|*LlkDfi^m;`#fA1|cHOT|md%ibg-|RgCWNmlT{aF$3(f)LW7{u** zi}yx7;D00|p?6~m18G)MF+t*jr)HMJ0Xn-p^;H%U7k7^}40y5f>$gVc( z>gp+bF~-E+rN4f0Pt`k?;c_^g%FYR<>~z$%-n7@)GL5DbfHYkwp?CZiCg-ID;a571 zTKNwQ40@fxcDx5oR8l#N30&OLta`{e}z8k||TTN}KD`Eg0K;0#9U#pOz~f*I_l=b8>6t$!Sv_z@V2w8~h(C-2@ z=12LW<$k9CM2ecB7UTGT1->RBbwu!2zoE7L13&mHH8(ex+U_V;X(TY2WGPAz!GjVs|jVmtCk-1ZibMS&GpH3UF*G zR)Sp0`DE>Gd8(_~ad+~)h@@n6SC{bCR9V)G=kukm@z0l0K0F z{cu!mkPkl&G=git>;g*W3ykmIzb{M9UToBF!8=@q&~G%0Z}mG0YH3}@)-*Ky^HlV> zuVC%m^vs-exc#;GghPbo7Z6y($iHvM)NJsTrKJ)yJy#b|;1#tcb*R|4^Wq;fA>wj5 zsmWKpy*o$}D>=>Lx)a%NyfMG?eq&KTlt<2U8^>aq8gc3CX)p+hB8NDndiaFj_yWM|*OBf_03}mk*MmzYQc4T==FBzdOnG)eD>1%Z9T`JLlC_SP494f z&BDOg0(F;GfO>bKU5Q*Cp-`Z`YD4 zJBOSqt6nX#ySPn|2PHp$UzN@p677C7ahYCkG)xhPyV*b@t89TP6UVLZ8}r{2OeW*P#>Tn2 zqIqBaM70)*bcqx;=L_mEO9@iFh+$jFB;wIgrwVj?c(Hf94Uwdz5#slzD@SgsAMS6E z5gLgFgMOz{&X3hi5SFr|`k=9qrWO_dO3$?0ZhX^z=E7@QHr^87|;d5%aE2 zY`(vCFqRpfEfZfavj)b{*qt^nc#pYoSd5pBnRAAt7Tsf!|E zK`}fx`VW8q-x~MVRZRii%$G}k_(=6nm*!YL|ALC+aIV~EaJl?S8?SF*(2Hk3yA*pJ zCiFI}(e>ujzC?;tGN)>K+Z`VY$Lb-G^G1?=n}a)%0U>)|fJx!o@A{3PDwNHXN;Wsw z8{L$deuJK35JRVt%Xe}Pa6(nc8?FHnn{$J^G_BVK0&@N)RdaR;D>W3q5|A|0?{V0P ze`vVhon33x1Mm`jp+DU>HgRFn5l3Ag;C_1~{|;>7W(FyBy)Lt6?PuZ>;XxjM?wzom_3ipS|R*hc<{4ZsaPeClJUoF$2OrveJ# zeJrgRcfz3~0tFVURsR{#kZN*?(uwSEo>{4+B+V2>#}41xPAg!Fz1STx@pLAGHphO{xtOxpv`v zaqARYL!dq%rrR>v*9aA)QhF;m@DRb%v?#PL7Hk(?SU9nu=9QIQ zErUs+y#3z%IN`IRDqqyLE@nf1#?@|{v)nfI#zK5{35b-^fq=z?V)&hlYg6Iie3M0! zNmS=e7_pF(Y2EMV2+R#;Gas`$m+@9y>+a3+l}mU*^d)0na49?89O65yf;t*>P+^lu zHJ`39$!cZo1QZeg+5imd+JcMB;RygZ6300kA@J@4)<(xQiH$%v>T$$f>1hpS?K}f3 zQxm+#@qd?(K-73#zy^>eF26^f$o>7(k?6VVYFEGOLFCnfdq&IoCuFvH`c7*phX7vO z4=`C9kSkg;@gg=9g;)C%r_KJMi>Mr^R}vciNndd=aWd<$52z;IR}1Zr1ZpQRTi79F z6%r)?WU*5ulje)9N>;c&=Xm3$ft3^+xv6T)b3o{Uskc86azs}V%w$c-RWue_hNT_# zQU|Mr%ZFv>st!#-N_PF+)iu44x$hvNy%W$9+C)egT@)`>H|UDD#V40>cOt6~0Y$N=ZTJ?n43r*=1-X5(@~hhWC-5&~ zDVdaBHIB(_$#P%n#OB5NeX>{}ymtBk62lHVDlB~_Hs^kuu$%$!9;QK31epYyOFWYa z8DOV%Hv6M0$=0j$8pWSIb&Xe7e1zV!;|~oQcOk3CX6x?lZd{ zUsm5Z|AG{bK3+WEiH|+c+(Xm{uO3%O1Lfeayg7?jq>H<$ia{FYN;UiuqvVzsAC2Zx zGiL2Bk^|qXHK%B~o<$XUAAa2_sKVS`l#PgtOgF({Hs2j4Qsw6*t=|s9CPaM@Y8)dd z_4V~t&9j*bG6U^%&&oiR_n@JDeZ+Nl8qt@`r4=K@!6oX9t~#-{bKTtO@DarC-cM08 zrAOPg&n=&6Pcxa#vT=EK!jwONrSQu5E8?}1x zcTYEDN&0H}4D=#Ptv4hkzHEl3yf_*ms0FT%HBb1r@2ZZ0Y~1?L#_W)bPo9fGp~W4bwWAv#$$Oz;Kev{B zA>Y;4Gb+*5zts&Q6lDD<^X)RLx1fu7p`=9z#<&3hptUtX&&oErxe85OUOE^#DKu8= zU$X!c@iSbO;$H|6H<-`Ii<>%T3LfFIE^du&A-}yz|%$JiH1045?#-H6f2KFPhJ6I($grh zlOgEBfo*eudZj;LI#p#5v$cMK!TKgkB7qFl=*QPPMPgP~rQ9SzKYom*@Y|a%EXBSd zv?0V16c8|aIfUTYVf}0cu)>W^E|YN@?BDJlnAk6!>}Sr+(6F$`A@zHY7NI)3y>M@D zSjVGwEYkPyW&CEmg-F}z9aPOHi<`HwgZ-|QV{i7Zq^WA}G;adf)AMDP>S76!kB!C?hsP`fji)Puw()7p{ zQdUODJp5UXWN*%#K{fFo+?s1}_2LbKe$ZmvI}jS)%KP5)T&g)p62tBT|IHd`4VU742IY^_Z@9PfnDp;qyA-w=zBR=zqf;)UV6JA`O-&OkkIGxrq9$!Bn+q9UxO5N&-qFw|1r#6{d ziov198&TDxU3vG-%3v&s~hSZ@C`SHJMK4!j~*tumRAEF7v<*&a}mU$ z8m#CFSkv2!HuDKSs(5)NM#%EmFb;I|`El?&#S8PZVv(kXGV|QiB6f#x^Vw<{=WZSmuq4luxV3Ya!8q6xfbWGciC7AU^CmVw%Ax2fNqPa7;Hu_^aufbo>I*1j3 za^7>mJZLeSe6rGi)BftpbuErf?XS!=i&o^)#w|uR8C0@mg0_ehS|UAS5K|!1!=uQl ze*9fr+JobX)^BM%H``rC8J^aX+95czI^o(rXkna`p!e6+@4vrGAp(=JZmAj`xT^lw zS?J&21-1$pF>9*&8c&-iQ#&fc{quDggJkUBytYQtN z9#AjGnAav%rmD5`@;{YG+ZmXi8&k0Tm0J5xMEA>wY*CTr2VxGPyML|Br%DrgTeZ#r z3c^uyrBtJP_b9HM4|gF6YxkK2PoF*&HZ^7XjzCG@yu(YX@jY9p45b$P7uCO4`QK&L z|5R~RS3ly&{+_BBZyZF~3@b;GX)9Gz718Gkyhfi5Jz#xd18C=-`VGT8rDY;`Ck?|Z zUp!A>o5qPj6&c~ngwqvP9&B~W=p*-}09>@gMOs{}j>qXPH+xl?@e_gEi_vQ4VIa3& zft>XiL9n#p1h_tskqKU39MFr1h#XHDBx7)RaQvNRcOe36Qh=$b@$HzGb^cV$J1Yzpte2-e5#T6vY zwTJtIhtpCJdZ`~<=5;>I^A3F5V|nCDOP9niyZgJN)~KULdY zhwSf)cHqtw#Q;U+biLj@k;Q2-`tl{c9w6ayp-)j*G|B`(5ZUH3FgNZ_1T97V`O@vd zfv##X`gx4gFW&odriQ&{yF*pB$~g#AV4|T!E!-|S%H)1rJ{3m+b3O)b{a(vmJ3MP&u@il%H! z&QxoLl@G$}moH^DtpGAVF!b|Fui%>A9xtGN^Z}U8R7wzdXo)^0M1R}TL8X{)7!Yg% zj9y=kSG%vpQ_;S?=R_5lsj@iOG(Z|ZdDe{X+xXaA0nQA0=h*nh4A=IBurt>h&Z0+J zudux1hAjRI>Cao9g70y&3+u-xnZ>9lXz)JaBV{L2->qct?tdPd|EvzpmzRW*NZfCy zU$Q@peeFl?@J~@g2Y>#F0O@37G;4+Q{De{^*{U^v?^R^s_Nj~Hhg$>McwYbb_qx8f zfO^`B+k;Y}=O>RB?nMl*HfZC2u-TOE?(3>YkERAqkx0)c@Y@x<{LXt76E;_I29U=- z{q`|vlOXS|0pb=3z^ce&FAfk}Rdmzo@*MHF4+3u;SHocLdX3@O#RE$p{Q!ZPs60YH z-jeQ26ix6EywkLwvz2TGHfE>J5GYRIyuMoWLb164{hypcIJaj|DdzySTH+{8Ghg0V zT~w{^&s~=;3~MrPkpE8s;lG3n&tD@EcxDSa5O!qe8$3(@ii6!j8C|Aa@JfA_1; zKfxT{NyVtGEHo6g(;r+i-*fEcRgtEqrov-mv$+60E}ONRF+2YC1jUdLLuVUkFxrNb zKmc?i7Z-2SY5+ur*=sL&w2QknHQit5<%8Vbwe{GAp|wh-XBR3s7<^U;I`4~#&2OD9 z3CZU_qZ~~d18?u|D!aNl!uZtXK>Ehen+Kbwb9S?#R3D)w^bP2Z7Y8`nHpmQGgYMb~ zBkY|Mck!0>;s2Qg{>SIsDx@F^`g9@kkV7f0xyD;K>U1_-Q#{CML`B3=liOu>UA@9a zdyPp``6Fv}G56K*UEI2CgR4T$JfmtZ(RciNw+6>?WcU?a#R!^xDG zEOAaw<)nK52;csONNpD{gsY1S9}K0GXp@KL})k}}PJWIqPI0#T#Y4#vU5^G^l? zxSkj9tVZYEAMTDprwj8nl7&36B*4jXz(bV=<9eH8I9FQ{$Ge$J**l@XWKs!fE^c1A^k_ZM?ZWfo^w&9?F4F}OU1fGZMD zf(7}|-Wq%4fexubG5(oYB~R6AJ74jV%OG+=8*E&?M4 zn$Nu#pAMlrdsGp#D}>;&W%`r>pOnbnbVbtoV^0-Rf-oHHXVFlvFsUZEcKyq*bbdpT3TKQ z9M&I7`#^=ft3zlG4DBmC2fm0MYef3xrrS08V157cE^8z{1TCf38y`a?OI&O2%XVCr zK~%?r^_{CuWrVV1_xqZ@JG(;82h=Yb8v3Bym2Pl^esLgc z%ImisrB41H<6E|d&xyj8bOqegjqbR#jfzex*d+h1cNvAbSCQ~0md_yZ038jtyr@S{ z#yN`I1rDFU?+9Z%#;(_=VPL*OoLE3Vz1A)hP`Tc&>TPlfN&kA~w z*M2#y=gA*EiiNyXr;fg z;(?DVZfqeJwI;vDP!S(3NBcJ0&9P#pQ&AoLp_UMoBYo7?7P&z^3XHt$J z8%S}L*`PZ}1A`hNRZq1so;FYTXI2XhD0n;iXn;=g_4f7PY>hRZg0j7$VsxXQvO4B7 z&G6!4Y0})flK+j>Nh4R#ST^g@TwGi&(l<)ZcdPt&+Vd#2yZiY54~jQSez%Q>9UsAV74qKz?G9re%#Nvh>TP=JXe`OS*@Q=Jind z7ah}8#A}@KEwHa8QEfNXd`#%Ek0Hn-1Kt3@{+7r>#FHFa^j;?va4= z`SMPBDpA7bX}Mhq&=0P9cz4bQVKd~5*+ohS%gdGMv?FbS1T^b%(m$LggfdlUU-ZI$ zXCUvDuFIYUWTl@8Xlnab9QEh8@gL2$KYCMHjhN3=_8WC|N03*1RZpgwq3;2{5scS}+SqUF6}|jO2jF=Rc+2T~2vXmmXs_~0NJ@_LEfPNS ztTUghoi00@v1l4Q15Lh2_xqFN+MvTBG8=mKA4B%*`El2NFwW<@mQIY`;z}>^c|RSm z3VLriQ|NjWZEyke^jqiM_gMxYHH-Pdc6ZQpVMpD6GPR=hp9rp0ZK>-Uukt$tml|svKIO+>eE^_*SD3@@~J8w!${~zhtWUc za@KjU;e+KczY2iR6?NwWCmBs6i$vx@2=?Hd!Eg{-HH+h$z^`Z+7kI@YF}(dTUKuFk z8~fb4s%ltOO8Bck1VBlA%^`Lf9$Ap(%(kiU!c2XsPMc z2S)(S8r&FM9aD8!6n z5D`6t6l3+2DqI>mg!1m%R9N{Wnd1DfdjYKVuqwp8E6=XqfH)^k_w@t%OMS^5sRf1z z&)*5KXcFthyHyiCqoW}q(W)1i@>ZEIwtQlq^h|SvWe^k!Ho2WYY+sBRp2NNzk8Rpm z2&&S2DfwDFzea6IW~bM!;DpIye%7)Mu7XD7OyzXr9SKrN(!G53+HJ8o+u z=XULAH;-M+Ev5D!%xGltlUuUkYrvgsQ@F1QS{CT7=kMR{mX136f$pOZ>5U$Hx-hb zChj}R_IkUBE%LxwgUrY`>Z2jNRPu}HHlN!;?^Q0=eANCfn7JE%8)N7UN^ZUT+pGQB zD|x^A+{52yKtH)$t;GbFpr*X^9XAF($8d1%D(B(q5$mT)>FA`curJ;bcN)seN2V1~ z5>(OkIUuE(La?#0{M5(;0b0H&T=J4ZELsBbsTZ(TJl$e)<>{IQpVMWCd*^B{Dj1lt z2zxRXTXe&;a;OWtf)Na6Lky7StH`PM?jev~r$413PwEhEHzL^>S3ZKV?Wfe?UyOT#}8=fcLn|F8Vi}>zf%e$kx?S7$r<#n*wpm)u-HhUhH2_00^?Rr zfu|$F{dNWa#mi0_Y$Dl(M%OdiHX2>QAfD_wVBm^1?!=h?2I|=gP=2Xe*kUhd09SbR zKV0F~cR-ffFnbe$Xj!0(z%RE_vNiBze5m9Z=F?}d3A*r*fD0&+Mx%CgkBU&AH@l;A zb=ziU$gc;Cm~PDj`$9zQ=Iw1s_9Ic4Q32LE(tS>{`V&yg<`Wq8i`R&F$6 zgZ=R9i3OM;`PBCA908>RxZ1kG(6tRdac;BMaIIY@Y=(R`==g}ou?q~oeSKON3($y7 zRO?4BzwP3s)-S+uElFK{wVqJHZPs5DFq`?=lF)2CU**Hk2PnKktki|dy&2ef2r89W z58$@6KmbH@-EB!drc|ElHE%)0>o|n8K-}7nuZqntcy~YsUIp`H-_*+$QyjKOb295j zFu0z2d__Lk$W&so?RxBXV@6W(eFVsVA=`?XKzeg;doQDv)bg%PCsaZ30!zoloOp5s!xmr|lDv4YkX0G=L zJ89_p>SW|=5;Qc-d~c<-l-zAoDkWpzJN1&vrmsu9c&X^xe1xBHmR?5i+BgBbUQ$a; z>OE*FM!~;z;5KhKQ=|=7X-(xFwK{X)pBL;y(H`4|s|FLCk0kzmM#R-LzP9m_fDsdd z!P(A)y*$rrpRY(>SOhJw#Om)jI|x3jv?ir;GBYbN^Bo?4L0!MCSX}(<=dYjie~}H>Ce1yx^EF0%eazgQ6U=rF?aDLg+Uqz z5?t1{X7&xtcYR|Ob7%9XD}RHWsUyNtl%#tMW4@AAD-5g`AmYn4T6(oiP9yCY%KQ79 zUkaP+3sohLV_^Y&huGZkw=vNXRK2oIVQDHir>>R{Jk_fHKT+ujja&E%Dfoo7{KLB# zR7PKW)A%^7?%g(?(;FA;PqW!<{)7{CxEcx&g3@d|h4yf?7B#WFfCM>74_Rht(hB{-I*u7dNWrQ z=7Plmf3BSW33cf>p{r7yO7bfEbDB$>2g&{R7;LfMb(_vq4IjZIEiY^ccl^&m1 zJF!LI{T&eec+4U%+To_96CqZ!!QY1J?IfrU@9_r=BK}9R^50+NRv}n?FK=OEQ_g-i zD)-nsb)>2>;`XS^&(uezdPlEwaz74#cfv))$~yHyuLpe!oUpCFzY&@L6`v^|2b=+v zGWz|~b2MK?4#o>`1Udhd--1bS@^y1`JUl&?3KdgBJlNOVEdAZxFBAI79{tC13F|t4 zcb$GcI(w0L&{N52h&eO>q$Oct5&pnXCNJS(+>3GQNf93wMv1C=)IHD>*_V$?3R%y& zZw^mL*ohUA>Tv(NKjml8OVAC`Njk*s@9BxYyFn2(H1sEMu>d16Xu^C^9`Px#GNi#& z7Nc$HB!K!y9UfYtVPjJo8Eu8<@tz8H@*6c*1ntH39!30GZ1$>vFSWe{dYLpQCuckt zS&`0Ur{?pU4KF%p7(k^O4c(PuIMmJmbl((qR0?^P{y{S;*(!59M1}eEl%Ab(;B%Sw zLZ8p(5oHue2F+oL=F|Ky1_oll8nvxkFMbkPWt{0Jbfd@t1~-j{)4xB07BVTwE~CTd z@08&@CHko{gOH%t?^|;AMC%<7o8dD6)&V|D457)@SLBjikAvdCSa9-21hnxU4VscG z0Oh(4!9tKL-WEUm=gm!6_wID&9`9~JfYUm218yer!R7K0Mywg-HE3csz?^03G=GEp z01ThjYU_odEfMpN6jh=t^d0yNcUtYA^IrpE+8s_RmN*{7-h)7`84GMD0ziByD8~ej zKi(n35%~Es6#G@taKj%eg6=XiGP(iwAOI8U4TdHwUVqTKi=u?!{v)A(;^`nwc;q442JL9Pm2gA2>|mr>D;z?ev(6J9#(Kqz;pjoyH={ zU~mjmT1QtGS={fFKQ*dap-lpC>Z8@}7(YYRB+&Qs^+kGp2VDA?Vk~$cO=J1moKE_g zN#9AYRW|tBrHnIJtqM0NCGMpOg=m)JUF=LqA!lMx$$!mH0n{Y8w8G=}wBXnB@dPWC z2Q|ke=W@%mlR?ltEhAZ9v7WKWR|ABq~z9~2Ll@d`^|3IizB#s-(lJ`_ zqvEWFBehUzHq#R!#hJrCohJhBsj}Aea97kigME>0Uy-|A*NYI0?|xfiVjWkrP-C6t za(6ay433a`kcLBU4N)LKcQzJyjxrQZ!6KBs7^Q(keQx+`p(!$1x5un=T*v2AJiH-qvu&+Z!Ur_w{p&j>swS zgt7oEnebzbOCK;11P5T>Mmajke0}t|nl?WqtWFg>j zhLQ-}&B8u;{$6s4(Y)37`L~cUR3}`*o-m=;zM(^p-mwbg-v7dz{wEGaDhwigF76n~ zAcP!*wNH61XT1F1V&3qc@t*d|J%~t3hN%PHpV2n<45Nej9SiR{J_}J)nvR|YcQs?~ zYs&uCUXg`tLlw<$f#-Hq{$`J^`T@ADPx`>G^#ZzM`lHb@iIJ7UE0ZYpyU~#kZY>T~ z+U8&f8yP(s6v~Y!a`za|K@k;gzu>CGd9%xqb+rg>&{hpf!5$%e*VWPe!RN<3@vLD_ zhTnMo3YbkQ^t}jrSN%A4XLfw`mQaE4UKvhE{P@w&0=B2c73h|nCZbk#24Sc1@zo)H z1O1mrv`G6DE4r#IyZx$kK_r3kp9R!Rx&dL z@hvN5)tdbRW-wej?^<%02On)|XOW;!OJltKVJet?xI+@cZWQq6o=YSQbVv&&L{Dkk z$oH70V#;(n0Zp5$mA5rgIzHnV?&<%>-djdh6@7c43J9X27=R+F0wSew=q{yEx>0G6 z?gkrBI;0Wl7K9@m2HhNxM!LK6&F%f)JKl$TVT||rePK8T2hTq1?7h~UznaoJ=Puho zXo~X1Fn&CFsY0)fj=Liyy^$TN7te^*`+N|?ZxCQ9yr;tWCYgkP0`gM68AnBjFwA+V z(*a8y0-{GLSPYNz59aysaAaPK{St^^(xhFMTj)skt9oVi-%&6_frf7v2TSH+UK57@ zy@4;B536tnN3^ptDR(*Wb@d!Z^9Si)A3PRCCvTB`)1j#wAiYWc7n!cLfrdtY5zsf0LP~6|<&zWzUuq z@2A}ud87-6va7}qJxr`)O6iJ0_C?Rq-zqs;CcYNAgk#N@uzlCYaGdBXSAO4oNqGk( zkHcdHaW1p;2e(h}-~DnY3($?#pI7Cl)AK`n4q@ApdaltTeA1>n;w|*K#w4!6+ElTa z@v=YXBj@m2!zNKzqyotJZxJ;_^xV>Sae0gLRw-!JM&!BT;cfQJz6mXOM(aC3M(%ihP0Hm+w^_wJ%}% zBBL69z2M>Ym$KCc2DxT9eH}|!vZKF$=i;{qYeP@K_5XmP zB=5r(&MfAMt628Cl@Q==T7j4mNgmOTm}w^AvuO$ɱJ&fY@HST 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%0D|jK?0sAtaxCK@Qo>@L}D0EvXLf{!r$jM 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

N?V>66@|mF<|`IasKFZHQjE^S<)DTyeqt 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 xF_2kMX=guhT*ME;NH0au?*Xy@32 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

6_6M(k)y~hWtIHl24GBvLP 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%0DQ^6h3eU31B;+!g9+ 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

|XJ{l@Hn$-e>o z1krh4!C2VG-0O-(-E-r0=T=rMAlD-; zIbm;m=DGa?aTw&L8A}cG)CoRecAvZs5=&c!nhQeSzVl+kVSHMOB%ft{&Y?qE3%ZG6 zx45-BqORk=CMKHD5Y2E|TAJjaG~nMfWvItAjHOFv%Rjvu$7?~De|O6z>H0p$QA&(( z37I@vZuVa6@*IfCPmUxf2lHNsxHaD=cE>`EQd17lxeAgEbX|(x-ge8cAk{-oU6}kl zY@z4_%L&zCqPU5S*>ZFaLV1%e2d|U6+$X2DZAvP_gl2U&T4=sF~K%+|LU_ zuCkHOkDs6wXadL2LKq)ti#5AJQ$K-rH6_c$sMRZW!6 zX>1%LtyQGFDn%S#{QCScEtf}Mcu;Yk|EFgBSGAJpa;zsOK8o(z4dJ4~*(g;0TQTCm zIS*}klhfB;lx@R3CzEzHH(TB5D`SlFJzpbE+b6P9MR3sOZ-Ae4Dt9=dwN<((OI;1H zm|p-u<{K_Vs-1JThO zctUyn!kcFom({p%)J9)DC+&8(H>P(nCB|K5T0gWVA!~Bg>!XBCErH~q+mY_99qD7b zELcxqPelcS+T_zZYDkv`lGZLSks6Mmyf^WPg@#$wz**gbj z3v4ZkUbvm(YiG71aT|)M+H+^_IkbtF;3Ox{)Fw4;r${hKu{D^$YBc|B zzLS{)nWgL~oj)9jIFGAn9THHr%XMAG>swKfNBA<bJCU(9-c?tz{7kbD)~^# zMn^7`9VT6w-?znzP8!4fvXJfNsJd{l$HbV&IV?dKfM=@<`<`{pDPB@OnJ7}%*$#-j zU8W=xM@viYh6c_^Z!s+_2<^R6mt#5Db_*3iHr>Ee^55o9{XHS_$l}CY=JJTo!*gXKEdIVPuH|c54As`T zJlE>2t)dB2lYgwcU$f|qy)0kkq9xv2ajZ@D%PCp(OH7YNX|cuVP+kleZ7bng}FvMtt~LELST2|YHnh5kXc#=L^;3WUZdTM zeYN}Wvg`IUT8|fSvkbIlNRf%JP?C`sMc?H-`;vv%{s9D%x2BEs~j8jOqL zohd?#zuM5Q_JEfx;;3`Kpu)064&SMEl63->TuNLy3N&a*5_*;FOAY9UxsH|QlnNEJ3jautbfY7CP^W6sEs~0W$G~1&OOvkzp ztyvrdhz|B%FX-WcK}fW^*aww%n6DT5Pu!xuq@Vu2P|A}OhQYY_$>yhvrMDE~xw^X` z;emnHZPB}5SDlHsQK#6o0AeV*P6#?++41RMT+(rbl-trW3pG29$~Znp|1sh?EQ`i- z2axtvwGh-#33yh=cYUPksC7!{fTKbVAdtsImpDy(p%cQz&BK&29-K**5@zu0sj-#A zgu%%eXX$*t=*B6**$sPtE=?rwtV#pRPSp`DAmZ$~H@E^*gS|CMOq_lDyrkPiQJxmY z=lEE-<-4vj#0qo zyo7A-5Ng~t`<&}1!Ofdtq4PhFBqeW~HWDrTj1<>@)pE(#Aeg}NPEY+SwEte)?c9El z6>`YL2xSWwufeFbz|t!8T7-QZ?~S-~ZRb3#o{m!1_H4q$VB%1_O_sLBmk9}Ma6LcU z7%I?v<_3Cyc4!C}r@iqoT;otFUc7i`*P2zgc0`qo`S|hUGG3uVZ<}=AFo!H68+F(E zBMw7r%8$$In1Pxe{x)7JJq!|IseeTkskp=K<^5*X&Oeoc?3$#5b;T)OWVCe5n*nLg zg1{EfJ*@-|*Z9JCp4=HBT`>VR2RCLWx%xd0+4OhCR4uEn^1du30||OD8*P!w#)h9- z*co6YeQG|`PPx=78&g(&c&TmM5X^GsqMvf8pQlUNsXo@@%3CHY8XS$o#w3k(Jfzp( z{^bxISkT@(NZQi)M`%Gm^d_SY7alxMeJEKe3RBEqngA8dr&YCHqVR1?_DH*i-5fb? z;l{oas_?l|)*Y&6oL9QI`n9eg*G-eE9?sCvumxsnAbs{S9Ox}|y972*%CSvg^$dBFv0F$DTiVpAm_@Y#&=Y!cFydI(MdY-q92Cy^IeiQdoN3ju`^RoP z$Y-7wybX#*PIcVs-nq7ih5_y#J3?h}57v-IiX80UqMV~!WBJjX4A5mzW!F2i@H(j^ z+*-4<)K=r2`WB5}jWI^&E7(StK2n*ub$-9iCjgr->%$h3eCpaDLF@7T4gd1zE2onS z4oaRreBJq8F{97BXT=^nE{qHeH>!3lgP-*?8M{I=2;jgC+wVw87VTsg9UpJO&V(rd z?~IL&O_D=wM8p)nY)}*iqTuk|jB9oyUm8nS>fX@m(2wDr^4SzRoIXX(uH3d5xy2^@ z_1$r{aPV0*-~2?Q_wPXkrr-!?OKYeq!QYdOk2hc9c^m07qUa;?*Izy({_B-WDHiUH;bBf?oTPOFcD_cu z%O?z`kjz|QGo1oIdzycoOIFS%g|*vY;a5ifV7~eCUuD76FTDYFenWQp!MU5fJF(-P zo<5;ztwjfwb`PA|v(to%j^H?%JM2Z@CaQRmE)pk|X2{$>QD*Nf$R*swn*ixAHt&wo zJE?~vvmkH&3a5e7R+7LLaQn-_QT>w4>mnWIzCoM^Qou8PlPo%QYp z2p>q=jmO$?-w2Ji-(Fe@1$-Z>&1YDdy4@B~#Of0C;1Brtzsld=p<0v60Ep6!iN}uK zdDaQ^wBO6^&HlRJ0W~yCesFkoH;9_+dZy%)g#>=*LdSNP^3tr ztm#DhJ$R*SZX#hSV=mt%Jv?g5vVU8v z>f;7<7$WY(&ki)T=Ic1cgkiXYoS}>DuXDjw+CXvUI&4|J^t3GgI==c~(V3rMGwsU0 zOUPa{_tgG@RPB`%#+TPmL|&+oo&WAq$iD;Q({wHKHT1FHdfUTg2gQ_Xo&VOVeFRq7 z)v%7xr%4?Bz)q4gj`VsTyW_l8J{BYpzknj0lI&Ple)DxuvYb1gh)xna?hMECm8Uik z4UC|HqMzp2Ak!cRnZ;rQ`qb}^vAE^K7a?cJ(R*??2pP~N4Tl#Dh*hb&&f6Q?W|1r+ z4N(XSHG064NB$@+J(ip3YY3vMT6t`z<)R9%O>XqKngXk=-HL^A3Uhh&%b+m`M|95a zh{+fU>F2eZy%f`nW%JyS((2A6;bk>=KP717=()Ew-iTVp4iqEWf>5f) z`0X~%fPhfqEg(>mPNO9=Lr>6X+POow>QHni3La2`5$UNmBhW(AbBJ|;Sg)1*F0WWD z%OZ9;yrVo7JEr5hzOLh9UI)V(IB_pauOU%k;oQCpc@qOrompLwQ=U`J)M_ zsO)5Au<{7_Iw!G^t3ks*I~-GNQBlTKZg6;`yPBquYy0VWj6c_LVy}Xi=)hvt($At#?}AX zpMt5HdyS^_R8?G{0rOx%lE0T>~AkH);+7LlU?oCLWzLRqkjo_p(_4jhj&sR}q-T0olC7-QTSGA2d#uE+pzJO{2fsW|t} zFf0c*a5P2+qOTlLr%>Vm+$40|WdVS1^3Al>_U zKj_E--3Gb&3QQY)XN9dHV*ddZMvMNPi%Wt6OL+3un*ttB1pJs^dcw{`~pgx4AebMKYg&x z1M!{Tw!aC!1S*(ErKCx%k|%t9Z2he4EfGi~dsOJRGsWh)bilIw3n|+zi6SFoiALNC ztKWV=^*`C#;7dS~27AS77olWkvU@OShD^p6{_6rvOj0(0R28tPhP^u~K*|pZ?s^64u#4-z9u?Kf>r=?OaM8@Uxk&3qSw;@LCm}L{tRrW#YfH zBEMHw;ot&eG_brt{FkF?{`ri4|HO&d<0)!8mn(z*Ne6Pq^V(-s13-Uz3xBfTq14@8 z8w?w9p|P<{;Gv2_(lQiTSD=E91epCZh=adDO^_LW(+U|Ydr5@^0p9_X^mF~5hyN;g zn(V(Ut#IPdG3DU{sSxli1-_aq79()6Ns`Iim{VFOnUqe#HjP+awQFi^%?XD|wn67x z09+5mLQ#EGJ`2@B8Rc95@{-j*X~Pv}78aRf$B!E{_>kB@2triL)XdCG%Yw%`pYdpA ziN1k>^UMGwMpi>dha}EBZ*4Q>;kEKRO5!|Qa}JXLQb7m1gU7qkHQ)R!_-{ZDhfV;+0{rhJ~XIs16~gl&H8 zJ{nnBHI?ir2+U}KvFCU)tj}@Q8#t(M!(jAe*D!7E*CE#G1Z=4QWZ*-bCe(x#n6%jI z+smtD?y(ytU=3TI5!$PG_o@CULanQLBcmK2@6jGx06Fdj8@Pg>F31{H!8x`J{hEZh zxOk5T^suay+I9*bZuUC>J~+hscw+7b5mcE`Fn70kxxh zpHWHZnvYMA6qk)tWHaqYx22L^D5+c`Z!&v{BNW*6d@6M8<+dEI3++nGE3Po`>dDdG zG`wD0x5MJeVQo%1`zzUgmq3;Nk27{KU@LXbY+hLS&3y$BT=7so;^ zY}2cvMrY2CK&8=Vl=!qj1Bw8zNqcoAW`l~dyqK$*nq{|+Ki^AX{e8<+=TIL12NznM z{JI}#f~;aD!`kqL-OgI#Ld<)TXEpsHKX?L*O}89{n765dOWN)CAaI30NWM*V&zy^@{`?<{rTN>?j?XEYE${c&xf2 zN28=U8`>BEm4OZb>+DO!BiX4UK{cy2T%nU-T2e-KOK=Q4`!*RqB$KfqUUaV*q6f)GhSU(a|n>3^>%@Et=r%QngUbgG9(tYz&ulI+%mK_ zQDBkz49e(go~9b#HxasfoBh|id!PsT1}g3~+T}ii%qqeQqO;Z5kW8T2VrO1_Of8j$ z;&aE>Lm{$UJ^8wIeysE|F;^jyDR6+c_U`y1VkAW-L+djlR?;SoPpq;53pRur4%(EU%#e7MiUtAfFxNIj!H^}JtPB) zN(uDXj7+LIH!L-99EC8b+jehvt*kDZndvl{=g2>J)>(2tdu-3m;FvBfXxIIQz5(OR z-4Wf_wPg=4L9A79_=T)xNSl)eO7E>%t9Y5Hl^*C<2;`BkuL4nv9y{)qO(A@6G`(X)$a1Cd| z)f$frx8It}d8Ns&Y>ut{-dXJ?*2dTgf-yJ@c+poo z)IFg9Z4o^-J9o;Hc$j+Ti&~5@F{u={dsLKamCap+D)vK^|*j3K|}*yakv?5jJd5#qw}4L*dH56RtEj zlK?-u@{UeOXk2-X&|q1()mXz$;iX3(}HV+l&q`7Ni9)YUh=L7Vcu#fwV;% z;Lw}l1fU!kEjCsHgJDQ?DHbND`X8}C8)|jDIcQ5wlR8->map(>19tm8GAo-3LHuxU zcW?k5(bVOMy|FG~MbD#qg1y4^q5-nwsJc3O$WgO({OumqZl$z4q)H>`=JZ6n1h)e+ z779{zGPz<$yu(5kJ9XbmA6~L1*`M8=;%Ol*MZ2~sQTXaBl)@0ZK|wAm(wN}y;n^3& zD}!s zVX{3==2GpX76<$g9B?sQcZ;Zuxk*^KOs_3)n?Cff-N=m=hGajEE?jR?0~_^X@LgH_mjNB@`dQy= zncG9l8rr{(#g(EU;X)G52fgQZsPtRF|6#i8*|{(YT85ZWBqs4$Tom;UlNLBK7^sDT z66}a+H9?SwWNX>6cpJ>;EH;UYi zOX)pHUxBTrNm~2WKk3yA7-b7;GlKxq`$lO$leMF92qTOgF)9nudSGNS%{1xM*hm+i40C z1=T!YzTsty$c`N{?AVgHw?MR($Jm~h!p9{Q%2HLUYpHsCCg&w7eQ89i>dS-kPm0u9 z-qajULE-H(wO?F`yLg`Y$s30lXXAb!vO-V2)j-KS{M^8UeZ--{8%M6PmB?-cvZcE8 zGT+kJR)5SH_L7BLN?cw2so9QGR7{gN2gybYQlx>3hOi*%w9{~y_QWa@Q5ZYHFzO5} z)Zt*UVTKNCq{GmePAMC6VS0BL=x^B&fwc$4Z|Dqk)%(|heU&y#BPYWy9j3<|;4JDw za^oHVET$KU5U^GMM!>cp)yrF5;-_;(nbCe`UBbL%Mx9B~y6-yD8>~vTK(Xk+^|oj3 z25`9o8A?@yNlIro16l}lUok>FxFg}uyo5A|;qq)_oLf)yNWgU%#y*nch^EunKIqSV z%NWeS%L|@+n$zuZIQ#tQ2u`owbHCNb2(3DW@Yl-5Y8924)@#MQRH1`!-fU#`mpuO# z*QdZc!Xu9^@=r4uuJU*`r>+{_)UBhSx}JuzhZ5N#YVL##0VU;-|9N65f=!M`M6y&? zmEpKjLx+oG)v#Ptsbf5A1$}A3Ga{*e2XEPdfA8e}0JD3i9D5_E7ZSD^V~^wFsA-6(OWZ;xESdopYVu-Db=4cm2w7?x~Wy{ zo=yuE`$E%h+N%|pnlfxx}7O6@MMY9MMQfs>GYJ;XLt0dFhc@RRSrPNpM-tvPLe zdg+@>^6^9^CcVp*{lfJ~y587&07|a=G4Y`q|D!px#YwwKBC)(RNi0b6q53yF&7vd# znOkXWet!$>-{xom?*%o)bu}KY_V~!?%ei?Al=g392u8h3jnS7&6!Fspv0L}|jqj)U z8!HdPc0^@kN$ytlwBFR%-ddG0BI7SmHfSn|Ir)q!+uUL(Yk7l><8)x8h%P&A4&2rR){sSRpUZb0b4Xv-t16vuYX+ z=9$B9UVi(vkj22BF-tdJ?@AMXT(;DsazJ%GG7n#Ua$njg_}#X1@6EO4H{}g=+_-YbI|u*t=aW8_wmmFtiPAjzC2u@m4Kot6!RtDZbZp1 zFRU2SpaVsb0hS{$VK8Yh$!uqC&Ws9zTTam2l81nC=Z*fl-Km9lnjurW#xFuhxs4J^ z!^vNvwu&a-67Y%~&l{Bx@}tA)qqz1VtwVEmK|^Z@6+2!;J&>-%H1ychqH$~I@$gpE z?!zu1_`aWV#%nebA1F#_3DDyIGVYpB#lAp$v^j=%AzElHU z4L(b~&q8r^OC0H?1_gB;*oMe&&5z$I*UO*#?vlEJW}G%VmrlAfBuXYGX zX8w{j;#}a#IV13aSL_AZt1K{0cb69x7ABRh$Y~lD?TBXojJuUw5@e7-iZ(h&7yI&R zBzIHJ*%m^sZk^7tW5-&JcSMdA1nU8-J60?Wq)yIjNMcWtic^&5V8VpU(2+1~EQIa3 z?QWSARU%%(Y;=w~N0;?8%3)S2Z;H|dgwg%&!h7bmw5hZLFoGS5hj zRr1_;?LBrI?0s?H%H6xEc~zxMB}ymvyS}1{?n!&0K>>f_X&h0GbTlnfd1*k&o`0TU zdi086T!G%L=~sGlCeK?)$&%_ak^b(?u=2NqWLaIVIo=1L;faJm1eI?XJ>IC`Oqj5n zHV?!hJrojR()c75$ZAiUjf#YE3$#h=_k6=)&>ZV-t5m7Ej!wKb8MAqPxKMqwP_8Dl z8U_GHdvjMlB950l_N$-KPcy*}^4Ox|txtwvkAyQWBCISH(3>lZHPAQ;v7L<}oe^V_ z6Ci}!!Nd=~6r~68Q1FbEdK!#XJCC8ja0yZb|2CVMJ&)g;t?m8>7aNkV&h09?@vXQ*BCaQYnnZ`w~z>}8qJZz`+#_f z(g({pc?}#i3D?1;`~E~0q-9%^wU=mX(P!2-bHBJ6^PVnBOL6+XNpBV%eVm=5XbtqQ zvF-)Z*5|rzJ?9hL+qTUwgHGnX<|*B#j*e5~DvyRClw_^l9-@t%aL@CYwaVYNvT`Z~ zpfr~$-Ot%o(zEb3#<1-2gxYVGFWMm4KEgLf>ujc)7vD>avtl-zTR1S3!q4udcnOw) z{(B{F2Le>G%kr2Y9=f1x2VPVs+-}4oBl-KX9eb`D)`*W@4TPaP&stsJRIaYSv$Yp{ z(dxHbw;+o88Xj=px!+#Gt=7v0&jj-q(u(Y?0FHd>az??X%G3SKA| zRrAc=6b4O07hmbj;eyriVO*)H5ox()x2z>B=6^ZkY);>LOcajP76)sWo0-sH5%1zL zA!HYS9m*>fWLg;jW~mj!!Os2-3FNMRqh;T#az=`j&O8x6RA#wQvIBD-=5y>YD4 z$eDd$_Cgu3Jp&l1=0+) zW!S5W2%wxn?J*U|(Z0CA-BBd8$e^)yb@(Bup08DfkM;?gI}>`<@L99UmfQiMq&GL( zAnSq!L5J1WVHV43-&u9*ids?rvs6sxr~OnW06|&L0=4yf#Gg`x*kohO3@hKLzl1@r z7HjmIA!I|{UT7f*M%HnRVQdr1{RYr-wO-}dp>~j!4ug9@Tv(X+9#>VQ$Dp*khDg!G z;0_WKn;3+9`lIm8V=WFK|7@lb?ryuj%<}TE=Sb0H71vn7T)fypnUdo+DO2|JlCh_S zUe3k0U1doxRZDkR*+gSMDoOXzjjG?ucXENcN@phLUzq*>`IT_R5t^Gy)uS+WfgM2B zuq6aKOcJ3ko!x}fVGi{WGsM~{K>_-oY!GGK=159_!3;^drL*V1#XL&SNwRc-ROA&K zNHbvb4KD4PszpF~wMaNU+xjqX?p#$;ZO{)k~ne&!CE}W#qH| zD$1c6x7mvNb)eEdTdj^=6+n2wF6ryoQDB5E19=56q_eolZPRgZC~;;;6y3%2!-Xii z@T?92yLa3C>;+~(SNF5^9=F-RCAj)65^G60_?JGM{V2m>s4f+Er{i4QQyJgrPzAul zdpW4|(oP*}KGT^{MNOWq^axw*hXh}XhQZ0t{5Sy@44Q8(S;&BIYHN#ta2N5N+TH~3 zT`y6AC{wFdM~Uup{&c7g1`5yhM4$rZW%bhz9Rb$}LQv zx^rydQU5k!jMcoz`0M(Z1OD0|dagNXF67y%i=ouU4IOH8a659>YHy1i8tyEKGw(RF zFBbZf+Y_M*ceRSKyozb+t`m2FKrnqxdwy?Lr9qG)lv3$574pyp%0T@ipIL zyi&SaSN*F+#Hz3)$AkJrt78chi`kLM7{jzFNg{U&q)$`gZtaNhe$hKJ-e)Gin?z+# zdXk1AfRb&^0r$Y%Dpc0M^U+whIt zZjjjAhA}!iyD*4@bm%)z&qva;8w=xz)d>)nb&y%dh|E>u@{UU@Oj)L@#{>PF=jX7t z!PxS?ujoR#PC|CN0GrgIW=&yVR>F+F69O3`divie*)fOj>k{8f)w;@(L|(7CYCCS# zS=PZ2Ea4aF`O$+5=pQfC#-~;nn?s011#e^smLsU>fU#ZLO4`=ZjFC_+OB?(0uEMm2 zV8m1vc}|8yv&y>kcD3P@>L9y)0S&_lPUX}o*`aOBWA)|5!%!ywci6sCIH)t6P3$G7 z#6rx;>`E_`TysZ96oORt<+JU6bIF;!2=!OfyR-53^nGg-D)~s)L7fUsLri8b=l$#~ ziZ~b9^vSLRsSqaB49V=07m`qqyIQ4U$fSw$IO{WtjYl~zNf8%=c!>^4u)(;(n5b(5 zjPVay;xuFvsK~6|dYh!4>{)tP-B0Ne<0SBY7;qEkV*_TA)ZxVr<`~NXdOaH6ok@jz z7Yv9}wnbhV5b(d9J`wg8X!3vly|oa3SrmTtMWhl`8ys!@&_nBwPsbH{zHblBa^2D% zcE0d-KaZ}&S)k~Q!|i?*?xpZMm#A!iujTVTx9h*Uxcv$n_(3ZdWt+4Zv9_A2POp%>;4AIdchvvyF8SZ}{`YR*|6T9@tGND7l>+tu|DPf_<&$!NBqLoX z?rT`8?2ZenwTc`6V$f1nCUc2n0LxOpqF%AS{_lFikp`N}hC=h@llrrrZ%(W6*<+QR zVKUp5HMNKf5XDdkRDKpa%}0gCLyCloyvKnj4kKD8^+bmv8sHRIlXlxTa4A-6F@8I9 zKiZc2EZ%@RO6BX+{6bm-Q3o4fwT3VD>;ip)TcNZ2SKthzfGSscCiRv2-`1k}Cm0WN zB2k%tvSx_nU9{K(A8?LjI%?{$Gy;D%v)~pt<)TJ6q&)FZ_aSG zt>n3G#k7sNh~LKa`h>;oW&7>tdL$DGy|#@P_X>%EK4qTCiEv-lAEDNyRXfSFg#@17i= z0)&AOBe45@5)Gl}mcw~Q2jW=tJ_cqgiaTgAH}W%OE^684H{V0XsJ#TRKy7u8V#itG z!2^cl=a^qWB!WODz4Ypgs8|q3CglY~!%gvNXx~6X)nDueksb!<6bb9KGn)2la{Uek zLDnUrDeXJEn}K9oN8?HigBMbQ>%T;R|gWNC$6N$YyqkfP9Yd%dMY_yml`D<3+2m z2fU>Uw{TN15)W{CGT7kRjw_Ome~95p{p#I2kX4UZ;@|V=eLSIFs4t=1jLh~bvlwmV znB3daPKW|C)MpSr{9K}~&WCsoq+DDo&Bm(L#{k63=tZV`u2dO<5u~a+(%qD%zF?M%!%LWS#0;FHvc+=WlRUST= zneXFqKSR6f^Fw=G3QLu`^i|*Ij8|$?8$iF{Q2_>ke6t1cFs=6T5>l?Dlmx;IyX|N-G_+=tuMiK&ntgd z=yDF*c7!VEPE>~;1*;?pXYuz(+*dZ21KZiQ_&d*F%(@MXg)#srA5nx}!w;R^xUKMV zQhJEwQdadOeEk zl}4O&OC}3`f&cvpbHaJu=3w>gtgknyltM`qztr|yDCkm?&0Qz(p4$mFIf};i7i3Fl zn>yE7d9c026p79*a_;r zC3GLmbvG8&O0iEeQ0hBHzYV$k1Ruw3cT7=s14kDOL!G_8?cBFX{lXqGP>t`d@p!65 z6y`P9#B?!L8@-LJO|u4=Jhor-T?no;TdKHLa#*d-W2e5u4~;cRr2d0_+B*vg0#m8s zP%+cu%3cf@R|)k0a!KBRgwr&%)+*x(X7u_zHzxfE=zXXbe)T0KV^6rh>Y)540yz3u z%1BB!9W$NJ{9k@O+saA2c~rK|Y!y;ieh9f%{J2b?o@;xjjJ9*weEtCH2cJ*}-iBGvD^KsU=ktywoQ>dW z*VnN%b_W~YkBlFUh?I-A1UvJno(8q#_LF9#EW>GRD|s1Rx3%e5im;>%&cp^J-q){o zeoKlEb#;q9c3uHB%+GcU(>X?5xQoACD70ELGVy^M&bF7+b-W^~HLK1Kr2%ui zVZ?prTqiTj?!?J9ZoQ)Nt0T@7_Fu)6iijAZtJ1T zyFAEoip2#%H#9iIwt2pC$@+VMfHPlnw5+~BZ{ARbI{NfhDt^9?PgMgs}Q0DK@r`n8LQUUN*YudlO!E}l66#IqevNeHI7e}A>;qnw90T9Ey1=`CU2Z#&Trcjkxh zMha}?$@^>k$ld%+kt;i+VfGMj3Nu}9;xgS1vhj7?s<9+2a*h34V?&75Q}|j}!}{kB zY|X)}$Tr-m$sZoq0u!}9e0C3ebK$0E(ay^*muKOiz~2tzWZJh&6Fa@nOQI=YwQ>tF zjk1&JHhxz_zW8&_Pc zkmRx)Z)(}$Qs!gVy|7*&X=K+m>G-e`I}goGOV5@*dbTAnI5Zr6V8s_M8WRpejHJ$h zT5nj%y2bS`929?bhanct7zksaobzl0j~%w?KCGwkuyW%)FnWJ zTcM^kkpNuQa-qXj6W3~=V|VBRD6F}88Wpp1wQ1o*rpbjp25?lO((DYmNz(U##eqH< z=^ij?#{jJFt70^$a|z<+9D9HA2jDsA2lhIzFJKV>nqxjy62y^z&IaggCxaDVXWQ|^ z=zD;MB4v#Yt&wwx`_ZI+q zqO1&wN0Z|aw!kA>g+pVZ>9CR6e&mtM-Av5`ShUBLvUl_E;OV<9W27^3jVr#2ulOM6 z#Rf^Fj69P4z~*uoJBW-sCnFk2ECpn4iKGJOdN-JzT1c@|I^mGcquu-rUf73qknjxM z%6XVqnf{?aMZ^6^GC=ojL3JZi@UjCwjEvB}0;FpK$)ITbZuAaa^p9k{># zm6;5LBsU3%7w20woi@jtoTp9J#Z3(rFnZ1${9Fo`yKm#KGaz>yNW3^yu(ftzq#^gAblsV7TMkfIL_6v+7m^6NdN44oua0=X zjTszqhnZ+vc;&{Win8V^TIiI_|FUMkd|xAM7r9=xzHG+c`wBV8xC z0zfA^aCcBhbvo7-%6%*a1F~^?HRJ~>fuN18O9kALiVmv#h;tooqL}+1R$R~LHclc` z^E)ah2lt%kCIHSB`@7D=@aNRO#GyKT%`DoLf9}%ATUXjjx2M}yc;vsM#aKLPG6PS1 zFw>hopSx?31ff3d47FoHvwoz#`+uOpsMFY{~11KBbzSA?PlGPX& zJwLhuddYoGh2;*h>TvJ}Y{kk0xW~C2DeKLpeDzGOwSLo7+gXi8d^Vt4?>xMS2h0T= zuswXr)W7Osl|}}9lQ1|aV?F~UnW(6pUye|FKaL|?E0t2D%$2e`<)YKsySryJnlO}` zMyQ-|6fGw$GDDY^@sD{Kqd+VUzz#Z}^iN!u7!OkiB zHrP40?(}X8FP*=*Kb4!Wdzjr*ZjZuM9$>S;=dU$fV?G5nv0ljbtWp4|4<l!W|F3QtkIW z!rO?d3s-J|Zo40l6~PSbG0yy?_8k!JuS7|*AYU6Ek#Qc?kI1;aQ7ap&qX)eCyewhF z4Do%Z_tmI5?pRStG}Fe%Kjv4*YoXkc#ifsvVLh5=Xq={ zKW0I8<18#sPW$37edR;1&JXr|MqEV;qVczn_N{zhoWYD_LI>d}3S31S_8+4^?t{$1 z4=$Cadq&z1M@O`ZQFH$?cwLRDeeL$iklUGAz4?`y`QF?$Dvmvnvy)|IuVzr>cgFaLD!GGS9EoGUX?qvU2-TMI@fe?W*wGm73J{<9$~jojWdJtGsJaGF_Qp zO>qugM1MSfhrW23F~3{6Sw$B;$7VnloQDQa*gsUrwY?3h++&xf618`bg}3(Jre60_ z9o;ng429K&IwBe=!VMA_Qmc5!l&Y9-P-HK#r3z9o-}=@ebcVm0(W zE!+1M*^6J=hSuY4*E|f;$VPHEP1+Azz}@Xs#H_XzPa)N}N}p}OFIITld1>|eC*(z+!ITd2RW=EN zT2XfM5yk|#W+JVACZVnDzzxTRolEwzd_Yz|@Mye%+RrECRJb-KRemkt zo@%wsFW-!PPW_k4TpI?Upj@=o*G0=ztLL(welR8RX0vHedbn+`o!q$aH1F_R|19U( zoRfihe7MgP() z5^wO0-U-q5>3NotfpjquOf`2|LXCyp@JtIc3CAGaTN?4w)|LzbaTdcMke?QmM%3z-dW{}CTZ3>E>tMpB}u{=ROS|}sb zfnuVn0Pwfrshqh~?t%g{GY^Y>+#K*u>Mw(8+L2!`!8*9?=_97Me8nGsD!tG4Pxf}I+kvS8^QUw8uK+yRr~rxLt4!~R^vyL1oSS8QPa6Q zu4*DPWzyt1ijy~4;s$~d&2j=9MbQ53Ya_8>HxXOMmFxnL|Wxo{|6ggMLy4X^~MF*xez$85rmJG!=BDvUDT z{0x99#kpL}s~8sg;AME7_Ae{*5dAXanrOR)y9yw5RYCZV5w9N3a92?TLg9gJ=5wrt zIC=7q58?Os**^JmSyW!*S-0oHyeXk(mg6f?a65&x`8}XER;9pXy348PJT^mDZQ)5C zn=|LI?>rwqK10wMes5#5uygdC>{wM}9FJ(Xr9!$&$MQMV`BdBHv?@p|hw5FJ#{RX_ zcaNj<)`hkQo_c0sbtlDU}R#1b&R-N#13TK}pr&KSYqb)*}NB2mcJ9I-Si-%w{IWSQPsO^ z1^xNW8fFX>`dzn~5nY1DHs+xzr)13j=eQ~pg|~7{H{MlVJ(pR&5NC4YmemsHh4Z6B zR0h^7i+B^>K2Ck^-9=7ir79Pan>@b7MlXL?rkTfi%n`mbo@Jl|NBstfcgIVt#JT~_ zkrC7w@xfqrf^muan=xR=19N$Ed|XkhlU5y3N_;22>~*HlG^o4BZIUb^X)rX-*xM6< zd;e}fs-Am2P88|fqaYDOP0P4 zvYrAUmp z0MZWmNtW@I`#Dh~Z-_i7N=M@p*KKEd>uf&z&0hZJbNBm){WNqhV$+*mD(ZkYZvqr| z02P*aPCAmYRgYQn5uF;Vky8t3d{ON97Wr5sVG5>Pq^CF zrky_LYGxj@G&gKZ2pQdu8CqCzZRJ`pgPaNDhp4GQNu3s*?<}E43ktu(Izk<#Xq>J64`!$eVf) zbc*JncGjArbpGnVddpr_dt-MCc)y+Zu;xTvTb^q#3}*v~6=NYSO4~2!0l3=w$#PM} zKe8oblN!@f=2Z}oR)>9`hF)n`}0j61o=uWHw0_VwKQG!Rr(;PyNe|$iR-@W zvK-uh{XSJday;wM(^0v#RH(6F!=-_J|G5Uq51WKxGtz)Iu&u0efcccF8Kv~WDNjTi z@y)fsEFWFi0!5f*8dTZx_kq~Mt^LU1cyQ7VO1V8)SZA_|-oi^tPNP;V2x_hSa2?vr=k1B`HTc|8fMLoem^Q9M=4@~>i z%g(O?xs(1odH-Z+gnE=N2o@l-L|gaFkjw()JlP*g9dnXGdv2_`K-X~G%EH(y(6z1Bd|R%lqi ztQK$1RNdjqdnfAa?v5uR5Fqmf12uT&2U!{Jn&E3Vw;)mnE)-tKWPvy}8ZwAvlG;fa zq9k^g>qlpkF~o|Z!DQ0KgMap_OK=EAyLI%cO^>j}ZncoI^)*FT5d=)1{Id7;Q$p(h zdhE-5`TK7cz*9n?f0taCdLOVbTJuL#tIJghQP-KEqN7Lqn$k-$}$(x2e2BX`LQ6kFv&bv1^XQDoTlHR$zbusLY5 zu*7Dm=NFZ~4IZ|LK)?Xw(RUziESnNyKwU7OVKdOQG7zjOVjQ}(6?v@=MGXL1nX@M_ z+C}~jupG!!blI_dAA2rL4|jTZijV4jT{lzu&T1#z(QIG!q3Vxuf#%ud(b!q!^dZz` zzIWsAtq+9OF-R$%Kyrm3BxnjhxzrgY5{%F5?K}$Oye=K*+^^L%)f`tmFbiGKfn$Kx zl1}=SpaOwsUxrz>aiix96e?lL<>jb2e_hjrK?hy<>PYF_9~}q4tBYlKD7V*uIQpfD9h-0K8tpdT^v1_qG1!9UO6_ zkL)T9^qLMo<0b2Jy;sNIfQ4WsRV=CXWTUX-*9l=~ z^G6Q#0Mm`NgXRK>uW0dTk|B8Y3@8tOnBc@9>#u3x9KpCRuVsRH$Q~gX|8J?JxsUwzkc4YU6${K29%8ul;4L z{OD<4G-EcDf{(_2H2L+@l#^N1Iqwa;bBMlJ;dJ@+mM+`*cq>bPl`9HLJ$bnMoc>pq ziF_1sxswN95jp;c!n!o)E;E5YvOB8HMPgyk(E}AR2sE^Zi0KU#u0d?^BHg*rQ!*43 zxx8zUXAn0da2sNXr#A(R7$FYk$J~h9LT^t0h!-~S^KuZ?+qkC4BVD>FBac33;-Fij z?m6j2ZLJ&P+wI|j_>nOWvf%rOEDHKpxz*m^y0Q%;dt!Z`=HmCV_im`t{=Dum+~qJK zJR+{3)SYz2{&Rfy$UK{=K$}+G&J^j8FdAjeZ}iqav3uCb@^)>|E1b>WcH}9ZN=OZTpYK$4H5) zOuomN46mN zxJ#N{!kADGze`%F>SGsilG2}Dzty7Ua9q9ouG_ev^vMfI?Etd3Llm9_g~)Q_H1>go zKlWeS892kGaeZcBc$ixl0TSy{1|Vvf_?m*p{w@k<*#sZ*EGWDoDt%cJ@j6m?-R;S6 z(+K2rl@!6Tb=@{u-uYB@X1|lrgdH(1CHoz)HS{=+NP{p#M!V7vlv68 zS;7~6hIZ76p&wLXrrxpIErY7>hTq59cQ%hsLhP=;F?MknjA!4{1Ai4X6B=f+imNBm zom|c;?DTEr;zkU;v$;z-{Y#37XYIKTx=^!Hdp38O_Zm+)Ps;YM28}koAr8VQ=s5`~ z=?5=R(ZIGd?F$E-f3Lj2`s+I&OG4jSN;B@uL?rr$3cbF{M&RJ$T#UXT&O&GV`#z(j@T7LjR+d=8zN0X ziNv^?(K!W2(}f$P!kCy{jHEgnNd_-*ZG`t_F}X+B%Z=CHb+yraqZ4d6I)DFKW<4FW zC2ah?I^EV>DJVMAe^w=2`O5=CKkGLDiq$b(g?E0+5#8#}kg+jhWViDUZOp_HCtTdy zSbw(Y-KC0m4^%l&mF`4<5S#SDh~RLK>-KgJ@gbJ3VONZA@b#pIZ>+k_cHg9#_-@~) z^u%O+$=eYhT-2>riWS=hOzjl`GJ@#o1%Q>I!1))^T5;aTBqNdCg7YmXjv@RJqM>eR zc^T6)|3t}Vot8R4?wwmsQ5X_k`izH^+gu=?&Y%DO+C4*NOK=Sn2H^6T zUGdJhP^IJ^`L&OvqRop!7F+=AwNPHU@5&7m(_#+6rXK4*nCW@WAYKx){BFlG^YcVy z#V_jE{-#ys{x92J{5?zfcx-PvYrWcJ$TDW<2vtmL@B+BoE*Pw4)2wRc?*HVfyfd_% z<;Kr1n1=1*m=YMZF9j?p7xEP-E*Dm`(=4&*p|lH*#6 zhdDh!*2|ty+4EgZlVr3?=5}2jtncf-cCJL|-+%u{Pd}iAb z9ZzqiO9HhNeMh`|>mVx|N~g?8waW1_{?Z#9nS0^8ttMRZZNuLfgT3&|{|Y%h2LDSd zCmDnfX7?h1gR}JO4aIL-J zvAAKi!`TOICLBWwnlisai&4E`thFge-n}72cwo4IZL{ShvWB)qW042AcQ6X19B(qNk0jBHgm9PirlA zEuYauhqZFT%!MlIBdf9}`mPSlaR}52!I{k^CLhKqnxP#j@s_(mGptq3Src0HqocDs z$$Uoxdzj@COtBrqHUYw2s>yp;@oRZyNlK^-d@d zS<3{NcB7l&6Y{43c1|EKJUD!2*s+|mH{h1D{@t!={pVb?-`|3wCM&gS^Y~a6@A!b# zt#)^Az&a&_n{BEiydSWB+Tprx#%YGV>={l)?xcN@KJ}d#+HOt7h&yWJ@p}qxLR9E& z%ChZF0Z~(NcszO@Q=@g^Mp_Ivzlcmh0_Po3fy<4nyty;*`nN2DMC*ZWM(NS{)kjWA z4yHO2FNl=K<5+RXmIh@EZ)j@hbQ#4?=W$M7Z|>F^n9g5XKvnpyeu%aEzcaToO`gsL zXDIj8v2JvVuvItawdEY0Z6V{yFKm~_Oq6z4)^T6yR?H=d6Zm^Dg_%)o)@a)w*~=5h zO|mL|L4y!=a3;^hG*@}Z#B`wdCtv2FL4Pk(zO~7)KthGJVYrW0lh+eAL4~N29n|UR znp_hu?RG_a?Dng_{gex&Ie8VV<<9h^4N>hQybvwMP-nqH$u|DouC_32JE`IRbfO8l z+^Xg|>G^N4XSY>d*mbKib5-rM$>qqH-F4A~(q(C-ox*q+V^~PF?H!aCccq`&nv?3e zAHJjw0Q5+%t^<%iwbG<}hL~Wl)m+jbbeqqc-c;3O>Z@%<6^aO3Oq{4zs)WlH)rMZb zQ(*m%pHvhYUK2pK@|B_L#iJS6xNadI)XB2MjtUcPelq_3c3w7zBbm?6R(-8b{*BUS zi_ab@ylopv8|$xnkBC(5a}1Zp4uSliy5x<^pVy7!7m$b;0iR||419+O@tr`#*bF`7 zs69L$%Xerc7}D^kNu#RGy#;04R9j!MN9=n4tIg43YE)0_*vBV z)J%X?4!%r&e#t0W6Ze zHTD7a<_~AjzE~OkRFH=b6nt2&8$?_7_T~PPVZP0?cA>ZVoYGS05^4uh6|3d5_x&;r zKPD&L(zEix_BkaxPx8fg$!q8qU*L0JdBt-Z8+TEqn4V!ufLqR$(Tr7&8?Oh1JYNit z0?LFw<4BR`P&HIJLlX6ZCrYdOK!7(i>=wTJ+C0@E=yQ0u`mwOD;2EthQ$wPXLvlz} zm1KFp+aK8%L~Pq(^+MeShf|nCL>taT`I_y`kcOD1W@neZT2W*xG7U5@+@*t)6JqN~!TgR?);pQepGXRs9atF=O= z$vtL^qr`tZ4SR_}x>#OHqVo(X^f~DzL5TU3iw#ad_CE1Yk`Y#E`S%5#IdLWK!RMTJgv#q&KEVoa|T;>r41zpTeFNFW>T;jQL zark`LO=VJ^@M5Y}H%;S*e7U0d0$G86MMT6|_=X2Dc3s}E={IVDosX4Y1TX_)(o$|D zI=AaRz+oAPyq*N~%Vox68$=#K5I3g*tXmH6sD>)hG2S#o+7gi{y)&PID-|YZ$jdcIY-YpvkBh7@lb{U`r>uV))}375476`wK@rUG)*G%f=-7|I|315*C0_ zqG^?NL@m?dZA?3BlHIc5J;TvFTnQRX$RN%} zJ)u*x9+PAe{QJLXotM%?Y@zqzvNtraEtqK3Z(JroD~XgFuo=+u>Q?`uPYf&vrkuJE#D%P$CY6Try;uKgZ}@ z-8{m-v7!jI3i316zH(%w^K=y2_rf|;=M+cDo!e}omSklc07b~q^!6M^4QJ~bAaZ1sdVDiSd7&Jc6FW8{vNGUpkVDxCWG50*n5l@z%;g% zkP9dG5-qz4E=BG(1hj75O3^}J9(Wx8^>SAlU3o-n^JD&EKhG1Zc2tvU(u(l^*TLn_ zy~f`}X>8uU^ma)$9$1bnqSeO4)$ljnO-Cv!C>Uh&bDRn8`A0t01e#E1>xSl6&(Pvv z4fb|Lm1wg0vl7xUu{HWZ-Yf+TdSiv&!-$Cy^Z9r`M#_LK1S6s247hpMzDl0xpW2wh6<`X zi@D0&&0U^&x~^YlmJO}a$Y-ZU`-|5S*I zU$wFSeoL~K_(T1pX*^9@pReU@euu)2~+5K-X;JS;-LKI_JA0ZOx_%hT;YG`MHP(xw{i$xmeXdwcyP|Y;~wr| z4+F@BOT=f1v|MNB-)1ySdGvfHRs_h92(8WsyBC#&?JdCMoeo|{Etf-Y2`0(>2yr0* z=r`n`nThEP$SnlNM?Jy(`R|7sI}%S!jZjKLX;r?Tc|yNkMCDl=-P(nkqZ+kI;=FI~ zE0zXUGD{8obVXE_1wJ7kDW0QcjVVW!l-5vEoCc8%oca?SPyH5l!4XAz&hh)cvWd0& zbm)pN@-r74+_gxkp>DamHfYa?geR(2c)ZG-6j?hmcwY>xDmDN_j%^skIaww-NW zvp%yuvOvO9v2_fO7sTDCd)cr|+H_aOMY5gm35tp1vkNH_I*=MIRf;iO1lXH_xIO@q zBREby-%rN&O0^%AQet(fpBvErqsqNVZ)6^Xcaq;oRRS3*^XfBT&(btS^>u&`T9?l& z{X(Z;E&Znt&}FZgy@U#vpTvH3=1QNTjazGuaC&9m`pD%IJpk+&&x8&XssAJ-GxmwO z$<-wpas$l8-LeRQjQvZ@V@^B57GP@@!SfhN;vzaB?c7pPj=SW}8fiwp>rG1l`)+n` zv9LpQ5GS1);M~;Ebhg#{xJSzna8;Kjy(1)^FmoM_x%p6CmC)i`6x?2`f!jpsM_tE5 z_5xwo8?7MD@ySg0p{*tCxg8EGO*Qx2Zw9qxi_c@sk_CoNj3G5s%@^u0!W!w>W_Yvx zQMa=YTASU0UK~|2lW#8#w*@tdB1*a$d3p5;1Z>=`9{+^x_p3{(RC9-e^2iagx8No; zZY+XQr@?1)zd4YCwpn!;-nmhUNX3k1OVUN~chOwoEjTqLV?#r+zA3A;vCOq+><_I>^5DSuCz+7ch^=tvSfG1y1ob>$&d2%Z!M+GbXhOBn(4EpF25uz33!z$eQ-T_0q_-M`gBE zI3;>t1N-vlQgsnE0kSvLlSdQ26WduErEQev6uU1t^0kqhp4mL(Z*0mI)lSM{rI{H` zJWEU&iC!k9Vn>vVYDd(c=5Qu(oPb_;6Z89S^yBF>Z3{n`3v361245=O$tJtlQd~%m zE439KL6|Wz@}N1A^03*o`#*7C=Z!UI&nWCo_=LE#YMt#o4eV2$(0NjrJto?zn z?7jn`g_AD+&~)|${17ak21DruSIpOdEy0SZN+UWgw6fou~w+b#fC z$K@J;qBG7aVJv=(S^!uTCC5)KW&vY@J0LOL7ft2|Fff}Nk)Y?_3CcwP0Lx?Y&Z8o= zls>?@`4R9fi6`>rQGM{dA80J@SHHz?#XuuHe336K$)>-6H z*x5YKnZodFS4>N`jT@{RY^S}+vVIfo!<17Ku}Pw+0{xC0Yq>CE@%wAI;I zSN6YOl+)LMTRi#|_?B|CeYNPo@)x%#(A!}?nxoi9>`b-Q%*gx?px-(HVcl>hFDK=} zAsfAw;+(O3%CNBl1Wqym;3@M?5qj}sv?J9kF1zGaSr66+Aj?RzXCSNm6ljgL?PzKU z=-8&cBBt$-|LLJI$18Lv{GmA8y?s7JNkSX;|9@miZ4FAAy>!z{`1X*9?( zNj+h*wBc{s%%EJ#LuC_R0x#byn_-*yl-&s%@hFw9Y`mXC&wdOSVae7tu2(#n#PPH) zQL@?KJL7&i<8a(XM09&#&Mt@?UpO<$1U10wYS3b-{qR}jd!2Om z%(HVNzA%m`5F~UddovgDhLz6^0);KhG6QhV(TC)-zuw-bANt+(lMe-of+U2(POu6a#+ELIQRCLAxECwyb;v+AhnrnsM^nuLG7}i|1tZ)dnKAV-hF9Nt=U4DNp8Hy(4lU(s z1^Q)FyQ_uXWLHB8S~`G;kD{m@xB3BT*m(u1Tg~69VJn@qVH&YYOVRzug!7cpo=C=# zR80*snZx!BB*wWHD7-RNL?IlD-}-qOF`n1V^*-WSe#eh6Lbuo0 z^L=0z!|Jrv);7@}*47DSk0`X0$?|&pV6h7Gux0xQ6P}QJ2@+`bcKpmq`o%b2y8L@B zX^e&;d`F7{<3w=&U74eg5XgZ52LwNfogC&{u|_a@Ej9x@OFJHz)$P1hc|TAQv1zdC z4U7g#g7dK_7f{*A9uJ4ewub0`XG-fXn>BL7nMYqP_BEMQaA|{D7`ih)^6oi*Z|qYk zigB_F?9R6&eD3F-z?{$$RxI^$6gB`9S_GbX_^sXD_JZyW(^l#~mzIk+-^2T%cXvO{ zQQ-dea6nXAyLX~9A$mh5PTm|rZ6+5$ck8_lRQzkD;_4awj-QnS#*JB9VZd}~XbQo; zYgKyVnYMYb0%Q~oS`j)TfaKBjIdntrA!V||QKeDEP2!u!nvB3;q0_VwGNp=K?P2VE z(7U%QubauAw>t01S$e5x7PAI`uKW{UvkEb&eT`Iwj@Xj%D3QH#{zkHe+xb>?v3h@w z3I*mjJ@Ap#&9zhS-B6SUpZ-vV6?stbpGl4E_BQ8-G&b$0pFVkXHAXMw*PPdReh5|n zG4}P#!dB^w$*jxdj{C&^5T(Y8VqQ&wE<$#nwGVT@tF6mp43Xnim75^(PW9$L{TUnE zCC4MWY061YUZFo8keSUBh;%6MNF8bZYu~W7IG(ehN7PtoS8M0=5xpa0zIh?oa^lhj z7E2O6S(R(zYRmMJ_3LJP6S_c;AHV(i!U+1z=SB84c1*b z19+}P^H+8&yu7daT9}VhzrS1FvU`Ui(TqcBblgHPw-HC?UgQ?>u-R|Q~R zvtRHGV;D!S0sHJee%+OAp&voWmyd=&ZZC@>G76j}7PHcSP(SBHmE7q0;pnv;_!-Lw zjHV=*$47w?!_C45p@P}H4+&WO$dMlO`HV~m%IRg-d#M-Fa=a~i@q%6FlTx+gha21o z80wa!rC_;skN|Z~s0qGG(hP$=4*V526;k+=!Jxw{>aotFVt|V8Mv1PMk|^rk=<>Vv zit@Yt*SWblBzAdzi0v(KD=kXPj!HfWR<;7jVa?l%_;eonE5LTeG5Q6TLPzqhHs1t( zA%FSn(!kAsR)yXuR|&qJZ8EnTYO3>^5Po^G+7utswe-^OC(sLzj`H=WQ}8;snyc+_ zk0}BU!98vJ@Hd7%>n`P^C61AH9+%=v=01ozK-}IvZtW^t_}$Ys@iVxl#q-jF({iEm zBAb@<>B;clx(`ClpLe~OA==c%dF;@V8t1d#-xP@1;7UiUsO&T|>V56INtWSxT@d2A zH%K*TQ-r9M_SwGUz4^I{mHEB)4P+_)+WLk37)PQhbpp26%eEm1ZuS!RcILY$8jJD1-k@$MbK7zfw`={voX`U4EbPKc+q-8qG~r3K*`0!+sn<9b!&qHLdh_ERs`z!a`byrtxI zpkrTkrw|k?6fFOZ)Ykjd`$VUA7>HPqT%kH9nqCAR-4pq|NEu=d{m5xHD^UH_7#ACAy~0Y`>j-sQ_sH>hP< zwqV}U;*xv0_r;^qrkqD@tJ1Guyc|huBV9kY(tD&aWKjQPLMfs`iAj+O+@u~aBBF6Y zoPc)w{zTPf4PhX^R>G6jNs~5pcTg`vP0iGghiLWvDc3E#Q+F}>veHy<$6K+t-KF2y z*ksZb8-*6@MSyG`f1ySH+x`rEZTln;q_E`LKAE_jxw>A?d(FJQ zze?I{%wQ~c5Ofzo`L9W0^1P?z$GTVND@a2kEd0E_*E2oIk)Ek^yV+CttR}Fr<&(Ys zc}A$pJDXfj26>kHeFKop$$(#lAhB8;Bt5)5An+>9$m6B2m}J|eVZ#-8`{K^%Fi7`_ zu}3%00u3&n-`0^XiT~>5gwRp5@xyY&)*0!+iO}#8bVzc@|ESrv%a!ZwGHH(f6HH3W zBt(yUDAg0!3@ngn8Z}|2D~y7|-VOdZPDNR^h&P_N(RjH~A~~<$W;RULYo~s8$u}>N znukZy&?~30+_F`<;(FaiJAG>s* z5*p$`GjXbBy@Z#(L)MN5{t5zU`m9(BkGJDuZ*Ll2inYO0%D>-|%EP_Y(^HD=ixe>i6M zN#TWY_Rx(pUAdJmqh1C(IcZtMZ`X61``7zuY;lWe6qV6fy|YvC9n zVTY~?_g21vKd(YAB;~EI5Sb_PnGJ5p5>}3d<1gdJvV6Fso15PYY^1Cqw7F=~v#-`D8-gAdyx{B6-56RzXzRECV>qXgOffOWRn)su;z~3`$S24T|&5X zH;MFYSfJwJQ9`30fbB90dT7?xw`~NZHh!S`v|yQMq@E`z_RgH?Gp1C-CTWk|#RiXn zhxQfVxKN7!{RX9Kt|#sNj+}5|Bz@@cSnQ!o=m33qeiuw%#@BjTOpsK+K zJ#}LjJRTwP#;taM3(vuag$=ytLjTo>%=91eIgweo9r z_VOFOet9&Gk^lU?{_zw4oL~Mo&+&PDlRwp&NdDt$|D!Pf-zvoa!z&?CPf+DJkc=Jw z|Nm#-9pKMIEW%v-=Kt{C{~w-BJUlNt^tk?gKmWVP`v1O(|NZ)#kan8jSG13YT7X==LUsa_45EP> zAL_s8hFW54`!^79vLH|6l2VO$G72>9pvibhH3;=glnV+W7n}{Sj*d>ASifCcMQD*_$7=fdUwG?z zuFZy1VVZ_2uPtj-60=KRc1Bt0Pmu`cR!SSL&3y=Nj(O9^{vy>uur0zCLBzjTko`B=sw@gXo(e$cf%?)1Fc*GKyH!NObeX>H zqg(Fv`|lNh6dwt-U$(SefY4b?VcHI0Vto$HdklI5gzDH^_PRij(dHi_B{(sIK2QVx zx|L)u*eJF*1>{!&yGo_Mmu(L%*=0;-)U!Yf<=&JPk>~Yh&avS>TOKs+2#Li2Zc@wSL6iqmx%{OaD;b#8GDY=8H z3AIr}@MN>!aY0(UaM1>0hQKJpr~kKicVWyp6Kx=+p%$}xWA{X;b zc{h-Gq0hOTO$o!-Z9C<6iA)~)eSFCR-4EDwGj z=l#E(O8&PmGO<1l37g7OOk)DT^UO2ZV?a1d$vaym5TY8$m6L1IUEo`GlWe_O$L{az ztB|Om@YwnF7!S=q?`EnfdTjy(J60D3;ZadS|JwQ7rm!lK@!6&dU#;JK>7(f2w#c{<-}YBToM)j)hX^9~;nVJHCn-leAi-xy02o;k&XOXa29ON+*26kn#-paO)r z?G`pqL$0VBfy6Qt*m9F=`FRIs?jc9-E8}i%X`uq^-_Iu3wdvOz)9pJkp@NQ%jzFe@ zvxe6+Kz#S9bg}u%AEx}-NFU%%)r!^=7&`-344VAm2N~T}^r|W3*8N8}=l|V$=36BF zb1(rLMKfyuF*Es<)0lXCaQ<)#pKdSuWuXITiF@(k z{jAW7Ep>msLg0S<0-c8(cc915Fn%zkb_9e-IuexzK8We{$y|9-0btq40pc4}baa>= z!!#cg8zO8It9%~rBREa$7fIRlUT7S$wR>MpDuMI`UG^;wUJILb6i+`UXcdf*T)J&; z8mHmiJbCwx_ZNUEdE|$8aHQ~9#d`tyM*k0cZy8l}xAhGxAcE2;NQWXNAt_xVCEbnm zCZ)Rt1SBM-k?!tJX^`&j+Vn>HSzPBn&vVte-t+Yx;~v8g?k%$ad&OLH&H0Np$(T`R z#!qJqyL!O-29R~SIHlcxEj^T=`Zn6};$tb)yflAk+-%AqC_=`w*aS2*><5-mS2JYi z)jpHK<3pII6jaFDvdk-vp`Vh^qw^OiLS0LL@I)rwl9M9TI`T8V)eN;)#e5)Ml8-Hz ztqRVmkv1Q=Gy$HSspF`qH)}r^FX7)RN?FONHqaE(Q^Qfzcdz*7`{8yCiI5bidU4`f zmK*!CYgTD~Y@e`5+WJ_BIQFVt2!oc)FDFht1+Wri&WWwsF*)-qguf`(x3qPTep1(3E&u^x z^x`KY7n{jwmvdYD!_K?X>2>U>=;hu9vA@%QyY9pmffTyLuU0Z9Ipz_y^?AWvI5H%Z zW#TG=v$;a!?TyP5b$igX>AC-qFh$j6 zrqRu%B^iP-v3OHELnn=&Vtd>#||5j=ceK?@e zj>7yh^cJ;Tfy2a|?`Wlp)XwtM4^Hj$F#n&Vi`>4Ai@=fML?3UE^ZLYhpv8?a1-tpR zkKU6vZ>*9x+}EwnIDlEZKDc2-zq=1t35sX4b1w?7JbeVo`d-pcLZ9tG7uX$EG2{9r zQbtn+;=Z*en{zpcJfWh{+i~aRT&SkaA;`i*(A~|HJW*!BVU&} z_RE8hazb7%{pm~Ll+~o!CzP>vGUS5aFPq`fzjb_oRK20j7f88hCAnlk9o`a;h49W% zkEsFWu4@JJPng2p)2lEZM}Er4t3s{{QnY+_H^+D0bpou8K|KR#4g3}L^#Hf97ToLn zU(WD#yr=W&{RgNyN;qHd6kZC!Zt>qMf63B=b_B%iFp<1S8$B`LiS;meHh@%06foB@ z#x;Fx6*djpY%J7w&_xOyZ@ouvpSYiwhRuiD4=PQ8QHyrS!~ySZL`a!- z!rp1~SRBYbu$27x25#wDDtWA*sPNu&MOh$eZL8JiA0HP8Q)yTk1w~tc7pH`bZ^6uQ zMCEDODRZ(P-X~?sd}$6KEZ2<{)FM9y#OO$7guW%)QKX{3b)k`Jb^0O`1eExSRhO^4 ze~=FOO@!neH=AM?3Cl;~?k7lY`>xV9J*a<>bM5p|zIz~>0^@QQE!%^%w!7OlDSnzQ z+FZEbL9|Y%OFZ8oT*CNi@5QqDjIOrIn=j1nP&PcmgIe47RfJnlkhqj(%0+OBW~eQ1 zi^ZbNUj;biW?%Y;d>qxp8o_}6#OQruY46x3YvY17Y+Xe6Nm#~Zx&Kj*a0Nh|`2|EA zq|R1brLBp}Xu_6!od5_!Om zNTQbU4p%^>hiSR&vBrV|@u|RAo^lbRZ(il;jP#{QT|rW9IW!s_dA&kB+|F)2Bn~FItrUFv)Pc6kdYt1_$ZAlYpru97-5!sn*HgF z=va%X&~1hHb%LBLSXI$7Vh_E}$XHQB{V)t^iX8!HC0~>cW4U@C-IR|{{S&-UJISZH zVI+>~kWdHBk>P z%RM)~gj}jrvdgDDc}!nP0q)mc)yR<)GTv{*G*^G{U_ShZt2Owi>?a5`@%-Ii!qZaY zO8MtLwxQayLd~6Hsmo=ohF7~I==Rybb@9pP1f7HY6JQ!Sl}a^2fS#bzY%BSZ{t%!A zy6R;y{c2*xeNk{ZTTOTon0kfLuy%yv%FG(w`3!od>5{QVaGqIMj0jKXo8 z{`6F#PASVt#A)KsAzyd-QoSv?c?it8<#x1`n_j+7k z)OgWjV&N|Hoxej&ROmY=kY=RSdU?d?Nb8Ny!p@%lOo(HibS#V)=oqpPj8)d#0VUJP z*J$ZA9t!nRhtR7MB0^ruPkDu>*-X zSOM>(p)y+Dgp{LkV7KHy+ z0N{CQ=pK2;kU#6eHpjO|U1^OK20A-Lfv5)fN@z*(I+@I{G!2^2I|-<&RWyvYAU8jJyf93gsPK+CWT`u9FJXk}nUO32lKLD`LZnG4GW6r{t02 zb%3rftvAZyO-UV>8#m3!uT`0d(4u`3XW>AaFca&xBhl-(6mZ}Ah%^;{xt7atMK3z{WD@_k2mXjWHLVI3? z{#Xfj=6S*prE(z&A&S4#N_YQih=cO9-P0)10Dj2fa!1IumyYPli_})zu?^#D2KZ1> zgV^&-Pj-d7?K(&%<1N926nP?qy>}?c6y>Y(T=z}wg6FJW{Q1#1WWe8jdwP(t?R|V} zf$wA7X_9GUA9VIl_H7Gy46}e@i`uQ1Wpz=o83r>_oc~lR`qTTnX9#|1L?(}4Dix?v zWKw2`egtliO_Pck?7~bV5>M)}W0zl?H~}N$)%xRsGC|8&verKLYFyWr2e>NoU!J`d z9PPzSMQ{Fy{zletK7QP)pJ~>|Z&7p~JO0dLrpi(q%Jh?1iWUoUe*TnTS}f828zEMW z`;Z#R$c;CHzwk|C zfIedDnqAl908YJxmznF-PN$^h-yjEs`fB#}m9eN2c?6Bcfo=~n33#dG=PK=XlteJK zsx3y)QOXDd$5ry{?S3%fOq1VaO#`V6Qx8XzSPQb$?eT*6r)W~WwmVhHu#M>dw(`V( zm#&tlj*+6u%DljY-w!#q z=6q~Dl+rX_rAq7UTSJen#>gRJyZrhB2>%_|F`}18=E88M*U4ks4=2#p{?Hd*n_lBF60>)p(KXFq&8{-XS;4)7t@#ZLUEEOP?QYnQ z$PklxZ%VrvBX^9gZ%~Ynx#1o0skL1`BB?+Ta0ZI-{T-{XG^Y}`di{rQ#IfXV z+1rE`!Uu}VniMkD=J4x&x&;44-b&|urX3pcJ)_3Q4D~-@Cak~`&S|%#vo+fOsGHJ1 zDkE11SU}ppY2$Uf=)yBfAe&s)@Xx&e9St=7%0AJf7mXofzeIXN`gZALYh(?Of221F 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 dict: + """Fetch weather information for the given city.""" + weather_data = { + "Beijing": {"city": "Beijing", "temperature": "25C", "condition": "Sunny", "humidity": "60%"}, + "Shanghai": {"city": "Shanghai", "temperature": "28C", "condition": "Cloudy", "humidity": "70%"}, + } + return weather_data.get(city, {"city": city, "temperature": "Unknown", "condition": "Data not available"}) + + +# Weather query Agent with model, instructions, and tools +root_agent = LlmAgent( + name="weather_agent", + description="A professional weather query assistant.", + model=OpenAIModel(model_name="your-model", api_key="your-key", base_url="your-url"), + instruction="You are a professional weather query assistant.", + tools=[FunctionTool(get_weather_report)], # Wrap plain functions as tools callable by the Agent +) +``` + +### 2. Create the A2A Service and Start It + +Use `TrpcA2aAgentService` to wrap the Agent as an A2A service, then run it over standard HTTP with the A2A SDK’s `A2AStarletteApplication`: + +```python +# run_server.py +import uvicorn + +# HTTP application components from the A2A SDK +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +# A2A service wrapper from the SDK +from trpc_agent_sdk.server.a2a import TrpcA2aAgentService +from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig + +HOST = "127.0.0.1" +PORT = 18081 + + +def create_a2a_service() -> TrpcA2aAgentService: + from agent.agent import root_agent + + # Executor configuration (optional); configure user_id_extractor, event_callback, etc. + executor_config = TrpcA2aAgentExecutorConfig() + + # Wrap the Agent as an A2A service implementing the A2A SDK AgentExecutor interface + a2a_svc = TrpcA2aAgentService( + service_name="weather_agent_service", # Service identifier + agent=root_agent, # Agent to deploy + executor_config=executor_config, + ) + a2a_svc.initialize() # Required: builds Agent Card and completes initialization + return a2a_svc + + +def serve(): + a2a_svc = create_a2a_service() + + # DefaultRequestHandler handles A2A protocol requests + request_handler = DefaultRequestHandler( + agent_executor=a2a_svc, # Our A2A service as the executor + task_store=InMemoryTaskStore(), # Task store; replace with a persistent implementation in production + ) + + # Starlette HTTP app: registers Agent Card and A2A protocol endpoints + server = A2AStarletteApplication( + agent_card=a2a_svc.agent_card, # Agent Card is served at /.well-known/agent.json + http_handler=request_handler, + ) + + print(f"Starting A2A server on http://{HOST}:{PORT}") + print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent.json") + + uvicorn.run(server.build(), host=HOST, port=PORT) + + +if __name__ == "__main__": + serve() +``` + +After startup, the service publishes the Agent Card at `/.well-known/agent.json`; clients discover and invoke the Agent from that URL. + +### 3. Server Essentials + +| Topic | Description | +|------|------| +| `TrpcA2aAgentService` | Implements the A2A SDK `AgentExecutor` interface and can be passed directly as the executor to `DefaultRequestHandler` | +| `agent_card` | Built automatically from the Agent’s name, description, tools, etc.; can also be supplied manually | +| `initialize()` | Must be called before use; builds the Agent Card and completes internal setup | +| `session_service` | Optional; defaults to `InMemorySessionService`; can be replaced with a persistent implementation | +| `executor_config` | Optional; configures `user_id_extractor`, `event_callback`, `cancel_wait_timeout`, and related behavior | + +--- + +## Client Usage + +### 1. Create a Remote Agent and Invoke It + +Use `TrpcRemoteA2aAgent` to connect to a remote A2A service. Provide the service base URL; the client discovers the Agent Card and establishes the connection automatically: + +```python +# test_a2a.py +import asyncio +import uuid + +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.server.a2a import TrpcRemoteA2aAgent +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +# Remote A2A service URL (matches the server bind address) +AGENT_BASE_URL = "http://127.0.0.1:18081" + + +async def main(): + # Remote Agent with service URL; discovers Agent Card from /.well-known/agent.json + remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url=AGENT_BASE_URL, + description="Professional weather query assistant", + ) + await remote_agent.initialize() # Async init: discover Agent Card, create A2A client + + # Session service and Runner; same usage as with a local Agent + session_service = InMemorySessionService() + runner = Runner(app_name="a2a_demo", agent=remote_agent, session_service=session_service) + + user_id = "demo_user" + session_id = str(uuid.uuid4()) # Unique ID per session; reuse the same ID across turns + + # Pass business parameters (e.g. user_id) to the server via metadata + run_config = RunConfig(agent_run_config={ + "metadata": {"user_id": user_id}, + }) + + user_content = Content(parts=[Part.from_text(text="What's the weather in Beijing?")]) + + # Streaming invocation; handle remote Agent events one by one + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + run_config=run_config, + ): + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + + print() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 2. Multi-Turn Conversations + +Reuse the same `session_id` to preserve context: + +```python +queries = [ + "Hello, my name is Alice.", + "What's the weather in Beijing?", + "What's my name and what did I just ask?", # Agent can recall prior turns +] + +for query in queries: + # New Runner per turn, same session_service to keep session state + runner = Runner(app_name="a2a_demo", agent=remote_agent, session_service=session_service) + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, # Same session_id; server maintains context + new_message=Content(parts=[Part.from_text(text=query)]), + run_config=run_config, + ): + # Handle events... + pass +``` + +### 3. Passing Custom Parameters + +Send `metadata` and `configuration` to the remote service via `RunConfig.agent_run_config`: + +```python +from trpc_agent_sdk.configs import RunConfig + +# Metadata key-value pairs are forwarded with the A2A request +# Server can read them via user_id_extractor or RequestContext.metadata +run_config = RunConfig( + agent_run_config={ + "metadata": { + "user_id": "12345", # User identifier; server may use for session isolation + "session_type": "premium", # Custom business fields + "custom_field": "value", + }, + } +) +``` + +The server can read this metadata in the `user_id_extractor` callback (see the configuration section below). + +### 4. Client Essentials + +| Topic | Description | +|------|------| +| `TrpcRemoteA2aAgent` | Extends `BaseAgent`; use with `Runner` like a local Agent | +| `agent_base_url` | HTTP base URL of the remote A2A service; client discovers the Agent Card from `/.well-known/agent.json` | +| `initialize()` | Async initialization: Agent Card discovery and client construction | +| `agent_card` / `a2a_client` | Optional; pass an existing AgentCard or A2AClient to skip auto-discovery | +| `RunConfig` | Business parameters (e.g. `user_id`) via `metadata`; server reads them in callbacks | + +--- + +## Task Cancellation + +The SDK supports cancelling tasks while the Agent runs, including during LLM streaming and tool execution. + +### Server Configuration + +Use `cancel_wait_timeout` to cap how long the server waits for the Agent to finish cancellation: + +```python +from trpc_agent_sdk.server.a2a import TrpcA2aAgentService +from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig + +executor_config = TrpcA2aAgentExecutorConfig( + cancel_wait_timeout=3.0, # Max seconds to wait for Agent teardown after a cancel request +) + +a2a_svc = TrpcA2aAgentService( + service_name="weather_agent_cancel_service", + agent=root_agent, + executor_config=executor_config, # Executor with cancel timeout +) +a2a_svc.initialize() +``` + +### Client Cancellation + +Issue a cancel request with `runner.cancel_run_async()`: + +```python +from trpc_agent_sdk.events import AgentCancelledEvent + +# From another coroutine: sends cancel_task over A2A +success = await runner.cancel_run_async( + user_id=user_id, + session_id=session_id, + timeout=3.0, # Client-side wait for cancellation to complete +) + +# The in-flight run_async iterator receives AgentCancelledEvent +async for event in runner.run_async(...): + if isinstance(event, AgentCancelledEvent): + print(f"Run was cancelled: {event.error_message}") + break + # Handle other events normally... +``` + +### Cancellation Flow + +```text +Client Server + │ │ + │── runner.run_async() ──────────→ │ Start Agent execution + │← streaming events ←──────────────│ + │ │ + │── runner.cancel_run_async() ──→ │ cancel_task request + │ │── wait cancel_wait_timeout + │← AgentCancelledEvent ←──────────│ + │ │ + │── runner.run_async() (cont.) ──→ │ Continue conversation on same session +``` + +### Session Recovery After Cancellation + +The same `session_id` remains usable after cancellation. The SDK automatically: + +- Retains completed tool call results +- Clears incomplete tool calls +- Records cancellation state in the session + +### Timeout Settings + +| Location | Parameter | Default | Description | +|----------|------|--------|------| +| Server | `cancel_wait_timeout` | 1.0 | Server wait for backend Agent cancellation to finish | +| Client | `timeout` | 1.0 | Client wait for `cancel_run_async` to complete | + +Use matching timeouts on both sides when possible. + +--- + +## TrpcA2aAgentExecutorConfig Options + +`TrpcA2aAgentExecutorConfig` configures server-side Agent executor behavior. Import from `trpc_agent_sdk.server.a2a`: + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `cancel_wait_timeout` | `float` | `1.0` | Maximum seconds to wait when cancelling a task | +| `user_id_extractor` | `Callable[[RequestContext], str \| Awaitable[str]] \| None` | `None` | Callback to derive `user_id` from A2A request context; if unset, default logic based on `context_id` is used | +| `event_callback` | `Callable[[Event, RequestContext], Event \| None \| Awaitable[Event \| None]] \| None` | `None` | Invoked for each Event before it is converted to an A2A protocol event. See [Event callback](#event-callback-event_callback). | + +Example: + +```python +from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig + +# Full example: user_id extraction, event callback, and cancel timeout +executor_config = TrpcA2aAgentExecutorConfig( + user_id_extractor=custom_user_id_extractor, # Custom user_id extraction + event_callback=custom_event_callback, # Event interception + cancel_wait_timeout=2.0, # Cancel wait timeout (seconds) +) +``` + +--- + +## Custom user_id Extraction + +By default, `user_id` is derived from the A2A request’s `context_id`. To read `user_id` from client-supplied `metadata`, configure `user_id_extractor`: + +```python +from a2a.server.agent_execution import RequestContext +from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig + + +def custom_user_id_extractor(request: RequestContext) -> str: + """Extract user_id from A2A request metadata. + + Clients pass user_id via RunConfig metadata; + this callback reads it on the server for session isolation and user identification. + """ + if request and request.metadata: + user_id = request.metadata.get("user_id") + if user_id: + return user_id + # Fallback: default user_id from context_id + return f"A2A_USER_{request.context_id}" + + +executor_config = TrpcA2aAgentExecutorConfig( + user_id_extractor=custom_user_id_extractor, +) +``` + +Client passes `user_id` via `RunConfig`: + +```python +# Client sends user_id; server custom_user_id_extractor can read it +run_config = RunConfig(agent_run_config={ + "metadata": {"user_id": "my_user_123"}, +}) +``` + +--- + +## Event callback (`event_callback`) + +`event_callback` lets the server intercept each Event **before** it is converted to an A2A protocol event and pushed to the client—for logging, filtering, or modifying content. + +### Callback signature + +```python +from trpc_agent_sdk.events import Event +from a2a.server.agent_execution import RequestContext + +def event_callback(event: Event, context: RequestContext) -> Event | None: + ... +``` + +| Parameter | Description | +|------|------| +| `event` | The current `Event`, including `content` (text / function_call / function_response), `partial` (streaming chunk flag), `custom_metadata`, etc. | +| `context` | A2A `RequestContext` with `task_id`, `context_id`, `metadata`, etc. | +| **Return value** | Return an `Event` to continue processing; return `None` to drop the event (not sent to the client) | + +> The callback may be `async def`; the framework will `await` it. + +### Scenario 1: Logging + +```python +def custom_event_callback(event: Event, context: RequestContext) -> Event | None: + # Detect streaming tool-call events + if event.is_streaming_tool_call(): + print(f"[Event Callback] Streaming tool call detected: task={context.task_id}") + + # Check streaming chunks for function_call + if event.partial and event.content and event.content.parts: + for part in event.content.parts: + if part.function_call: + print(f"[Event Callback] Tool invocation: {part.function_call.name}") + + return event # Passthrough, no modification +``` + +### Scenario 2: Filtering events + +Return `None` to skip specific events: + +```python +def custom_event_callback(event: Event, context: RequestContext) -> Event | None: + # Drop non-visible events; None means skip (client never sees them) + if not event.visible: + return None + return event +``` + +### Scenario 3: Copy and modify the event + +> **Important**: When mutating an event, **deep-copy first** to avoid mutating objects owned by the framework. `Event` is a Pydantic v2 BaseModel; use `model_copy(deep=True)` for a deep copy. + +```python +def custom_event_callback(event: Event, context: RequestContext) -> Event | None: + if event.custom_metadata is None: + # Deep copy before mutating framework-held state + modified_event = event.model_copy(deep=True) + modified_event.custom_metadata = { + "source": "a2a_server", + "task_id": context.task_id, + } + return modified_event # Return modified copy + return event +``` + +### Notes + +1. **Always deep-copy before mutating**: `event.model_copy(deep=True)` recursively copies nested objects so the original event is not accidentally modified +2. **Returning `None` drops the event**: It is not converted to an A2A protocol event and the client does not receive it +3. **Callback runs before protocol conversion**: The returned event replaces the original for subsequent A2A conversion +4. **Performance**: The callback runs per event; under streaming, event rate is high—keep the handler lightweight + +--- + +## Architecture Overview + +```text +┌────────────────────────────────────────────────┐ +│ Client │ +│ ┌──────────────────────────────────────────┐ │ +│ │ TrpcRemoteA2aAgent │ │ +│ │ (connects to remote A2A service) │ │ +│ └──────────────┬───────────────────────────┘ │ +│ │ A2A Protocol (HTTP) │ +└─────────────────┼──────────────────────────────┘ + │ +┌─────────────────▼──────────────────────────────┐ +│ Server │ +│ ┌──────────────────────────────────────────┐ │ +│ │ A2AStarletteApplication (a2a-sdk) │ │ +│ │ └─ DefaultRequestHandler │ │ +│ │ └─ TrpcA2aAgentService │ │ +│ │ └─ LlmAgent (your Agent) │ │ +│ └──────────────────────────────────────────┘ │ +└────────────────────────────────────────────────┘ +``` + +--- + +## Full Examples + +- **Basics**: [examples/a2a](../../../examples/a2a/README.md) — A2A server deployment + a three-turn conversation +- **With cancellation**: [examples/a2a_with_cancel](../../../examples/a2a_with_cancel/README.md) — Cancel during LLM streaming and during tool execution diff --git a/docs/mkdocs/en/agui.md b/docs/mkdocs/en/agui.md new file mode 100644 index 000000000..d04d35f4e --- /dev/null +++ b/docs/mkdocs/en/agui.md @@ -0,0 +1,335 @@ +# AG-UI Usage Guide + +[AG-UI](https://github.com/ag-ui-protocol/ag-ui) is a protocol for Agent–frontend interaction: it is event-driven and pushes tool invocations, model output, and other behaviors to the frontend as distinct Events. + +- **Live status display**: the frontend can observe and render the Agent’s current execution state +- **Streaming output and progress**: supports streaming text, tool-call progress, and other real-time presentation +- **Human-in-the-loop**: execution can pause to wait for user confirmation or feedback in the frontend + +[CopilotKit](https://github.com/CopilotKit/CopilotKit) currently provides multiple UI components that interact with Agents via AG-UI. + +This repository exposes an AG-UI server bridge under `server/ag_ui`: `AgUiAgent` aligns a single AG-UI request with the internal `Runner.run_async`; `EventTranslator` maps framework events to AG-UI standard events; `AgUiService` registers URIs and a POST streaming endpoint; `AgUiManager` aggregates multiple services and listens externally with **FastAPI + Uvicorn**. The frontend handles presentation and interaction (CopilotKit is optional) and connects to the AG-UI service through an AG-UI event stream (e.g. SSE). + +## Installation + +From the repository root after cloning (enable the `ag-ui` optional extra): + +```bash +pip install -e ".[ag-ui]" +``` + +Python 3.12 is required. Core dependencies include `ag-ui-protocol` and `FastAPI/Uvicorn`. + +## Quick Start + +Mount `AgUiAgent` with `AgUiService`, pass the same FastAPI application to `AgUiManager`, then call `run(host, port)`. + +- `AgUiService(service_name, app=fastapi_app)`: registers each Agent’s POST route on the supplied `app`. +- `add_agent("/your_uri", agui_agent)`: the Agent URI must not conflict with other custom routes on the same application. +- To add business APIs on the same FastAPI app, register routes on that `app` before calling `manager.run` (the sample adds `GET /health` via `AguiRunner`). + +Below is a minimal version consistent with the `_agui_runner.py` / `run_server.py` examples (omitting `/health` and similar details): create `FastAPI` and `AgUiManager`, pass the same `app` to `AgUiService`, register the Agent, then `set_app` and `run`. + +```python +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import InMemorySessionService +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 + +load_dotenv() + +HOST = "127.0.0.1" +PORT = 18080 + +# AgUiManager aggregates multiple AgUiService instances and starts the FastAPI app with Uvicorn +manager = AgUiManager() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """FastAPI lifespan: release background execution state held by manager on shutdown.""" + logger.info("AG-UI server starting") + yield + logger.info("AG-UI server shutting down") + await manager.close() + + +app = FastAPI(title="AG-UI demo", lifespan=lifespan) + + +def serve(): + from agent.agent import root_agent # Your custom root Agent (e.g. LlmAgent) + + app_name = "weather_app" + service_name = "weather_agent_service" + uri = "/weather_agent" # Frontend POSTs to this path to trigger Agent execution + + # In-memory sessions; suitable for dev/debug; replace with RedisSessionService in production + session_service = InMemorySessionService() + + # AgUiService : binds to the FastAPI app; add_agent registers POST routes automatically + agui_service = AgUiService(service_name, app=app) + + # Create AgUiAgent : first positional arg is a BaseAgent instance; remaining args are keyword-only + agui_agent = AgUiAgent( + root_agent, + app_name=app_name, + session_service=session_service, + ) + + # Mount the Agent at the given URI + agui_service.add_agent(uri, agui_agent) + # Register the service with the manager + manager.register_service(service_name, agui_service) + # After set_app, manager.run invokes uvicorn.run(app, host, port) internally + manager.set_app(app) + manager.run(HOST, PORT) + + +if __name__ == "__main__": + serve() +``` + +For a more complete, runnable layout (including `await manager.close()` in the FastAPI lifespan), see: + +- [examples/agui/run_server.py](../../../examples/agui/run_server.py) +- [examples/agui/_agui_runner.py](../../../examples/agui/_agui_runner.py) + +Implementation details and directory layout are described in the [README](../../../trpc_agent_sdk/server/ag_ui/README.md) under the repository’s AG-UI server implementation. + +## Advanced Usage + +### `AgUiAgent` configuration overview + +#### Application name and user ID + +Supports static values or dynamic resolution from `RunAgentInput` for session and application scoping. + +```python +from ag_ui.core import RunAgentInput + +from trpc_agent_sdk.server.ag_ui import AgUiAgent + +# Option 1: static values — all requests share the same app_name / user_id +agui_agent = AgUiAgent( + weather_agent, + app_name="weather_app", # Fixed application name + user_id="user_123", # Fixed user ID +) + +# Option 2: dynamic extraction — parse from each request’s RunAgentInput +# RunAgentInput is defined by ag-ui-protocol and includes thread_id, state, messages, etc. +def extract_app_name(inp: RunAgentInput) -> str: + # inp.state is a custom state dict sent from the frontend with the request + return inp.state.get("app_name", "default_app") + +def extract_user_id(inp: RunAgentInput) -> str: + return inp.state.get("user_id", f"thread_user_{inp.thread_id}") + +# Note: app_name and app_name_extractor cannot both be set (same for user_id) +agui_agent = AgUiAgent( + weather_agent, + app_name_extractor=extract_app_name, + user_id_extractor=extract_user_id, +) +``` + +#### Sessions and memory (storage) + +In-memory sessions are the default; production can use Redis or similar (you supply the Redis URL and credentials). + +```python +from trpc_agent_sdk.memory import RedisMemoryService +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.sessions import RedisSessionService + +redis_url = "redis://localhost:6379/0" + +# Redis-backed sessions and memory (recommended for production) +# use_in_memory_services=False prevents the framework from auto-creating in-memory services for omitted deps +agui_agent = AgUiAgent( + weather_agent, + app_name="weather_app", + session_service=RedisSessionService(db_url=redis_url), + memory_service=RedisMemoryService(db_url=redis_url, enabled=True), + use_in_memory_services=False, +) + +# Session timeout and cleanup (managed internally by SessionManager) +# session_timeout_seconds: sessions idle longer than this are marked expired +# cleanup_interval_seconds: interval for periodic cleanup of expired sessions +agui_agent = AgUiAgent( + weather_agent, + app_name="weather_app", + session_timeout_seconds=3600, # 1 hour + cleanup_interval_seconds=600, # 10 minutes +) +``` + +#### Execution and tool timeouts, concurrency + +```python +agui_agent = AgUiAgent( + weather_agent, + app_name="weather_app", + execution_timeout_seconds=1200, # Max single Agent run: 20 minutes (default 600s) + tool_timeout_seconds=600, # Max single tool call: 10 minutes (default 300s) + max_concurrent_executions=20, # Max concurrent executions (default 10) +) +``` + +#### Accessing the HTTP request from Agent / callbacks + +The framework stores the current request’s HTTP object in the invocation’s `run_config`; use `get_agui_http_req` in a custom Agent or callback to read it (e.g. auth headers, tenant ID, request ID). + +In a custom `BaseAgent` subclass: + +```python +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.server.ag_ui import get_agui_http_req + + +class MyCustomAgent(BaseAgent): + async def _run_async_impl(self, ctx: InvocationContext): + # get_agui_http_req reads the HTTP Request from ctx.run_config; may be None + request = get_agui_http_req(ctx) + auth_token = request.headers.get("authorization", "") if request else "" + tenant_id = request.headers.get("x-tenant-id", "") if request else "" + ... +``` + +In callbacks: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.server.ag_ui import get_agui_http_req + + +async def before_agent_callback(context: InvocationContext): + # Callbacks and custom Agents use the same API to obtain the HTTP Request + request = get_agui_http_req(context) + request_id = request.headers.get("x-request-id", "") if request else "" + tenant_id = request.headers.get("x-tenant-id", "") if request else "" + print(f"request_id={request_id}, tenant_id={tenant_id}") + # Return None to not intercept; continue execution + return None + + +agent = LlmAgent( + # ... + before_agent_callback=before_agent_callback, +) +``` + +Usage matches Callbacks in [filter.md](./filter.md); it also applies to `after_agent_callback`, `before_model_callback`, `before_tool_callback`, etc. + +For CustomAgent, see [CustomAgent](./custom_agent.md). + +#### User feedback (human-in-the-loop) + +`user_feedback_handler` runs after the frontend submits tool-related feedback; use it for logging, updating session state, or rewriting the tool result text passed to the Agent. + +```python +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.ag_ui import AgUiAgent +from trpc_agent_sdk.server.ag_ui import AgUiUserFeedBack + + +async def user_feedback_handler(feedback: AgUiUserFeedBack): + """Invoked after the frontend submits a tool result and before the result is passed to the Agent.""" + logger.info("User feedback received") + logger.info(f" Tool: {feedback.tool_name}") + logger.info(f" Message: {feedback.tool_message}") + + # feedback.session is the current Session instance; you may mutate the state dict directly + feedback.session.state["last_tool"] = feedback.tool_name + feedback.session.state["user_approval"] = feedback.tool_message + # After mutating the session, call this so the framework persists changes to storage + feedback.mark_session_modified() + + # You may also change tool_message to alter the final tool result text sent to the Agent + # feedback.tool_message = "Modified message" + + +agui_agent = AgUiAgent( + weather_agent, + app_name="weather_app", + user_feedback_handler=user_feedback_handler, +) +``` + +**Notes:** + +- If you modify `feedback.session`, call `feedback.mark_session_modified()` for changes to be persisted. +- Changing `feedback.tool_message` alters the tool result subsequently passed to the Agent. +- The handler runs after the tool result is submitted and before it enters the Agent. + +### Customizing `AgUiAgent.run` + +Subclasses may override `run` to preprocess inputs or postprocess output events: + +```python +from typing import AsyncGenerator + +from ag_ui.core import BaseEvent +from ag_ui.core import RunAgentInput +from starlette.requests import Request + +from trpc_agent_sdk.server.ag_ui import AgUiAgent + + +class CustomAgUiAgent(AgUiAgent): + async def run( + self, + input: RunAgentInput, + http_request: Request | None = None, + ) -> AsyncGenerator[BaseEvent, None]: + # _preprocess_input / _postprocess_event are custom hooks; + # the base AgUiAgent does not define them — implement them in the subclass. + modified_input = await self._preprocess_input(input) + + # Delegate to the parent run to execute the Agent and emit the AG-UI event stream + async for event in super().run(modified_input, http_request=http_request): + modified_event = await self._postprocess_event(event) + if modified_event: + yield modified_event + + # ---- Example placeholders for subclass-defined hooks ---- + async def _preprocess_input(self, input: RunAgentInput) -> RunAgentInput: + """Preprocess request input, e.g. inject extra state or filter messages.""" + return input + + async def _postprocess_event(self, event: BaseEvent) -> BaseEvent | None: + """Postprocess emitted events; return None to drop an event.""" + return event +``` + +### Cancel and SSE disconnect + +When the client closes the SSE connection, the server can cooperatively cancel the run and checkpoint partial results. The `cancel_wait_timeout` setting (default `3.0` seconds) is how long to wait for cancellation to finish; if it is too short, streamed content may not be fully persisted to the session. + +Full details and a client `abort` example: [examples/agui_with_cancel/README.md](../../../examples/agui_with_cancel/README.md); wiring: [examples/agui_with_cancel/_agui_runner.py](../../../examples/agui_with_cancel/_agui_runner.py). + +## AG-UI server module exports + +Public symbols exported from the `server.ag_ui` submodule include: + +- `AgUiAgent` +- `AgUiUserFeedBack` +- `get_agui_http_req` +- `AgUiManager` +- `AgUiService` +- `get_agui_service_registry` + +## Complete examples + +- AGUI basic streaming and tool calls example: [examples/agui/README.md](../../../examples/agui/README.md) +- AGUI cancel support example: [examples/agui_with_cancel/README.md](../../../examples/agui_with_cancel/README.md) diff --git a/docs/mkdocs/en/cancel.md b/docs/mkdocs/en/cancel.md new file mode 100644 index 000000000..b988e75b0 --- /dev/null +++ b/docs/mkdocs/en/cancel.md @@ -0,0 +1,1073 @@ +# Agent Cancel Mechanism + +During Agent execution, the output may sometimes not meet the user's requirements. In such cases, users often interrupt the Agent execution, provide partial feedback (indicating which outputs before the interruption were unsatisfactory and what should be done next), and then let the Agent continue execution. + +For this scenario, the trpc-agent-python framework provides a Cancel mechanism that allows cancelling an Agent's ongoing operations while preserving partial content (content being streamed by the LLM, tool execution results in progress, etc.). This mechanism is based on a checkpoint design. During execution, each Agent checks at checkpoint locations (after an LLM streaming output chunk, after a tool call completes, etc.) whether the current Agent should be terminated. If termination is required, an exception is thrown, and the framework records and saves the partial information to the session history. + +This capability has been integrated into all Agents provided by the framework. Custom Agents implemented by other services can also be easily integrated. + +| Module Type | Module Name | Cancel Support | Description | +|---------|---------|-------------|------| +| Single Agent | `LlmAgent` | ✅ | Checkpoints set at LLM streaming output, tool execution, and other locations | +| Single Agent | `LangGraphAgent` | ✅ | Checkpoints set during LangGraph streaming output | +| Single Agent | `ClaudeAgent` | ✅ | Checkpoints set during claude-sdk streaming output | +| Single Agent | `TrpcRemoteA2aAgent` | ✅ | Checkpoints set during HTTP streaming output | +| Multi Agent | `ChainAgent` | ✅ | Exception propagated from its sub-Agents | +| Multi Agent | `ParallelAgent` | ✅ | Execution cancelled when any sub-Agent throws an exception | +| Multi Agent | `CycleAgent` | ✅ | Exception propagated from its sub-Agents | +| Multi Agent | `TeamAgent` | ✅ | Cancellable during both Leader and Member execution | +| Agent Service | `TrpcA2aAgentService` | ✅ | Cancels remote Agent execution via the A2A protocol's cancel_task interface | +| Agent Service | `AgUiService` | ✅ | Agent automatically cancels execution upon SSE connection disconnect detection | + + +## Agent Cancel Mechanism Design Overview + +### Architecture Design + +As shown in the architecture below: +- When the framework starts, it creates a global `_RunCancellationManager` object to manage Agent cancellation signals +- Users run and interrupt Agent execution through the Runner + - Users execute an Agent via `run_async`. Before execution, the Runner registers the current run information with the Manager via `register_run`. The SessionKey is a triplet of (app_name, user_id, session_id) + - Users cancel Agent execution via `cancel_run_async`. The Runner receives the `RunCancelledException` thrown by the Agent and completes post-cancel processing (injecting partial streaming messages, partial tool call content into the Agent's session). After processing, the Runner generates an `AgentCancelledEvent` to convey the termination information, and the cancellation reason can be obtained through its error_message field +- Agents embed checkpoints during execution to integrate the Cancel capability + - During Agent execution, within the `_run_async_impl` implementation, `ctx.raise_if_cancelled` is used at various checkpoints (after LLM streaming output chunks, after tool calls, etc.) to check whether the current execution has been cancelled. If `runner.cancel_run_async` has been called, the Agent's execution is marked as cancelled, and `raise_if_cancelled` throws a `RunCancelledException` + - Common checkpoint locations include: during LLM streaming output, after tool calls. Cancellation during tool call execution is not currently supported +- Agent services automatically invoke `runner.cancel_run_async` through their interfaces and obtain cancellation details via the AgentCancelledEvent returned by the Runner + - For AG-UI services, the protocol does not natively support cancellation. The client cancels Agent execution by disconnecting the connection. The Agent service detects the disconnection exception and automatically calls `runner.cancel_run_async` to support this capability + - For A2A services, the protocol natively supports cancellation via the `cancel_task` interface. The framework already supports this interface and adapts it to `runner.cancel_run_async`, but it requires hash-based routing. In multi-node deployment scenarios, configuring hash-based routing can be cumbersome. A simpler approach, similar to AG-UI, would be for the Agent service to automatically detect connection disconnection and call `runner.cancel_run_async`. However, due to the current underlying implementation of the a2a-sdk, the Agent continues to execute after disconnection, so hash-based routing is temporarily required to complete the cancel operation. + - For custom services, it is recommended to implement Agent cancellation logic triggered by connection disconnection. This approach has very low implementation cost, does not require hash-based routing, and the client simply disconnects from the remote Agent. + +

+ Agent Cancel +

+ +### Session Management + +When an Agent is cancelled, different session management strategies are applied depending on the scenario: + +**Scenario 1: Cancellation during LLM streaming output** +- Session management: Messages from the start of the LLM response to the point of interruption are preserved. After this partial streamed text, a message "User cancel the agent execution." is appended to make the Agent aware of the cancellation event +- Effect: In the next conversation turn, the user can indicate which text was unsatisfactory, and the Agent will correct its output + +**Scenario 2: Cancellation during tool execution** +- Session management: For scenarios where the Agent needs to call multiple tools, e.g., tool 1 and tool 2, if the user cancels Agent execution while tool 1 is being called, the system waits for tool 1 to complete, then skips tool 2 and terminates. The call information for tool 2 in this turn is removed from the session history, as if the Agent never executed tool 2 in this turn. Similarly, after tool 1's call response, a message "User cancel the agent execution." is appended to make the Agent aware of the cancellation event +- Effect: In the next conversation turn, the Agent can perceive that tool 2 was not called and may proceed to call tool 2. + +### Limitations + +> **⚠️ The current Cancel mechanism only supports single-node scenarios** + +`_RunCancellationManager` uses in-process storage (`Dict`) to track active runs. This means: + +1. **Cancel requests must be sent to the same node running the Agent** +2. **Cross-node cancellation is not supported** +3. **Applicable scenarios**: + - Single-node deployment + - Client communicates with the Agent through the same connection (WebSocket, SSE) + - Cancellation is automatically triggered upon connection disconnection + +## Basic Usage + +### Basic Example + +```python +import asyncio +import uuid + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part +from trpc_agent_sdk.events import AgentCancelledEvent + +async def main(): + runner = Runner( + app_name="my_app", + agent=my_agent, + session_service=InMemorySessionService(), + ) + + user_id = "demo_user" + session_id = str(uuid.uuid4()) + + # Run Agent in a background task + async def run_agent(): + user_content = Content(parts=[Part.from_text("Please describe the history of artificial intelligence in detail")]) + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + ): + # Check if a cancel event is received + if isinstance(event, AgentCancelledEvent): # AgentCancelledEvent + print(f"Run cancelled: {event.error_message}") + continue # After continue, runner.run_async will terminate + + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + + task = asyncio.create_task(run_agent()) + + # Wait for a period then cancel + await asyncio.sleep(2) + + # Cancel the run using the same user_id and session_id + runner2 = Runner( + app_name="my_app", + agent=my_agent, + session_service=InMemorySessionService(), + ) + success = await runner2.cancel_run_async( + user_id=user_id, + session_id=session_id, + timeout=3.0, # Timeout for waiting the Agent cancel operation to complete + ) + print(f"\nCancel request result: {success}") + + await task + await runner.close() + await runner2.close() + +asyncio.run(main()) +``` + +### Agent Custom Service Examples + +#### Approach 1: Connection-disconnect-based cancellation (Recommended) + +In long-connection scenarios such as SSE/WebSocket, it is recommended to automatically trigger cancellation by detecting connection disconnection. This approach has low implementation cost — users simply disconnect to trigger cancellation without requiring a separate cancel interface. + +The following is an example based on FastAPI SSE: + +```python +import asyncio + +from fastapi import FastAPI, Request +from fastapi.responses import StreamingResponse +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part +from trpc_agent_sdk import cancel + +app = FastAPI() + +# Create Agent and Session Service +agent = LlmAgent(name="my_agent", model=model, instruction="You are an intelligent assistant") +session_service = InMemorySessionService() + +# Cancel wait timeout configuration +CANCEL_WAIT_TIMEOUT = 3.0 + + +@app.post("/chat/{user_id}/{session_id}") +async def chat_endpoint(user_id: str, session_id: str, message: str, request: Request): + """SSE chat endpoint with automatic cancellation on disconnect""" + + app_name = "my_app" + + async def event_generator(): + # Create a Runner for each request + runner = Runner( + app_name=app_name, + agent=agent, + session_service=session_service, + ) + + try: + user_content = Content(parts=[Part.from_text(message)]) + + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + ): + # Detect if the client has disconnected + if await request.is_disconnected(): + break + + # Send SSE events + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + yield f"data: {part.text}\n\n" + + except asyncio.CancelledError: + # Connection closed by the client + raise + finally: + # Trigger cancel operation regardless of normal completion or disconnection + # This ensures the Agent execution is properly terminated and partial results are saved + cleanup_event = await cancel.cancel_run(app_name, user_id, session_id) + + if cleanup_event is not None: + try: + # Wait for the cancel operation to complete + await asyncio.wait_for(cleanup_event.wait(), timeout=CANCEL_WAIT_TIMEOUT) + except asyncio.TimeoutError: + pass # Continue after timeout, the Agent may still be running + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + ) +``` + +This pattern is already implemented in the AG-UI service. See [trpc_agent_sdk/server/ag_ui/_plugin/_utils.py](../../../trpc_agent_sdk/server/ag_ui/_plugin/_utils.py) + +#### Approach 2: Explicit cancel interface + +If a standalone cancel interface (e.g., REST API) is needed, note the following considerations. You can use this approach: + +```python +from fastapi import FastAPI, HTTPException + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.sessions import InMemorySessionService + +app = FastAPI() + +agent = LlmAgent(name="my_agent", model=model, instruction="You are an intelligent assistant") +session_service = InMemorySessionService() + +runner = Runner( + app_name="my_app", + agent=agent, + session_service=session_service, +) + +@app.post("/sessions/{user_id}/{session_id}/cancel") +async def cancel_session_run(user_id: str, session_id: str): + """Cancel the run for a specified session""" + success = await runner.cancel_run_async( + user_id=user_id, + session_id=session_id, + timeout=3.0, + ) + if success: + return {"status": "cancellation_requested"} + else: + raise HTTPException( + status_code=404, + detail="No active run found for this session" + ) +``` + +**Note**: This approach requires that the cancel request is sent to the same node running the Agent. In multi-node deployment scenarios, hash-based routing must be used to ensure the cancel request reaches the node executing the Agent. + +## Agent Cancel Guide + +### LlmAgent + +LlmAgent has checkpoints set at critical positions in the execution flow: + +**Checkpoint locations:** +- At the beginning of each conversation turn +- Before LLM API calls +- During LLM streaming output (each chunk) +- Before and after tool execution + +**Usage example:** + +```python +import asyncio + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService + +# Define tools +async def get_weather(city: str) -> dict: + """Get city weather""" + await asyncio.sleep(3) # Simulate time-consuming operation + return {"city": city, "temperature": "25°C", "condition": "Sunny"} + +# Create Agent +agent = LlmAgent( + name="weather_agent", + model=OpenAIModel(model_name="deepseek-chat"), + instruction="You are a weather query assistant", + tools=[FunctionTool(get_weather)], +) + +# Create Runner +runner = Runner( + app_name="weather_app", + agent=agent, + session_service=InMemorySessionService(), +) + +# Run with cancel support +async def run_with_cancel(): + task = asyncio.create_task(run_agent()) + await asyncio.sleep(1) + await runner.cancel_run_async(user_id, session_id) + await task +``` + +**Full example:** +- [examples/llmagent_with_cancel](../../../examples/llmagent_with_cancel/README.md) + +### LangGraphAgent + +LangGraphAgent wraps LangGraph as a trpc-agent-python compatible Agent, and also supports the Cancel mechanism. + +**Checkpoint locations:** +- Before and after graph node execution +- During streaming output + +**Usage example:** + +```python +import asyncio + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.agents import LangGraphAgent +from langgraph.graph import StateGraph + +# Build LangGraph +def build_graph(): + builder = StateGraph(State) + builder.add_node("process", process_node) # User-defined processing node + builder.add_node("respond", respond_node) # User-defined response node + builder.set_entry_point("process") + builder.add_edge("process", "respond") + return builder.compile() + +# Create LangGraphAgent +agent = LangGraphAgent( + name="langgraph_agent", + description="LangGraph-powered Agent", + graph=build_graph(), +) + +runner = Runner( + app_name="langgraph_app", + agent=agent, + session_service=InMemorySessionService(), +) + +# Cancel usage is the same as LlmAgent +await runner.cancel_run_async(user_id, session_id) +``` + +**Full example:** +- [examples/langgraph_agent_with_cancel](../../../examples/langgraph_agent_with_cancel/README.md) + +### ClaudeAgent + +ClaudeAgent runs using the Claude SDK's subprocess mode. When cancelled, the subprocess is terminated. + +**Cancel implementation:** +- When a cancellation request is detected, a termination signal is sent to the Claude SDK subprocess +- After the subprocess exits, partial responses are saved to the session + +**Usage example:** + +```python +import asyncio + +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.server.agents.claude import ClaudeAgent, setup_claude_env +from trpc_agent_sdk.models import OpenAIModel + +model = OpenAIModel(model_name="deepseek-chat") + +# Set up Claude environment +setup_claude_env( + proxy_host="0.0.0.0", + proxy_port=8082, + claude_models={"all": model}, +) + +# Create ClaudeAgent +agent = ClaudeAgent( + name="claude_agent", + model=model, + instruction="You are an intelligent assistant", + tools=[FunctionTool(some_tool)], # some_tool is a user-defined tool +) +agent.initialize() + +runner = Runner( + app_name="claude_app", + agent=agent, + session_service=InMemorySessionService(), +) + +# Cancel usage is the same +await runner.cancel_run_async(user_id, session_id) +``` + +**Notes:** +- Cancel will cause the Claude SDK subprocess to be terminated. You may see `ProcessError` logs, which is expected behavior +- After the subprocess is terminated, partial responses are saved to the session + +**Full example:** +- [examples/claude_agent_with_cancel](../../../examples/claude_agent_with_cancel/README.md) + +### TeamAgent + +TeamAgent supports Cancel during both Leader planning and Member execution phases. + +**Cancel scenarios:** +1. **Cancellation during Leader planning**: Saves the Leader's partial response +2. **Cancellation during Member execution**: Saves the Member's partial response to team memory + +**Usage example:** + +```python +import asyncio + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.teams import TeamAgent +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.models import OpenAIModel + +model = OpenAIModel(model_name="deepseek-chat") + +# Create team members +researcher = LlmAgent( + name="researcher", + model=model, + description="Research expert", + instruction="Responsible for information retrieval", + tools=[FunctionTool(search_web)], +) + +writer = LlmAgent( + name="writer", + model=model, + description="Writing expert", + instruction="Responsible for content creation", +) + +# Create team +team = TeamAgent( + name="content_team", + model=model, + members=[researcher, writer], + instruction="Coordinate research and writing tasks", + share_member_interactions=True, +) + +runner = Runner( + app_name="team_app", + agent=team, + session_service=InMemorySessionService(), +) + +# Cancel will interrupt the currently executing Leader or Member +await runner.cancel_run_async(user_id, session_id) +``` + +**Full example:** +- [examples/team_with_cancel](../../../examples/team_with_cancel/README.md) + +## Agent Service Cancel Guide + +### A2A + +Agent services deployed via the A2A protocol support remote Cancel. + +**Architecture:** + +``` +┌─────────────────────────────────────────────────┐ +│ Client │ +│ ┌───────────────────────────────────────────┐ │ +│ │ TrpcRemoteA2aAgent │ │ +│ │ (Connect to remote A2A service) │ │ +│ └─────────────┬─────────────────────────────┘ │ +│ │ A2A Protocol │ +│ │ (Supports Cancel) │ +└────────────────┼────────────────────────────────┘ + │ + │ HTTP + │ +┌────────────────▼────────────────────────────────┐ +│ Server │ +│ ┌───────────────────────────────────────────┐ │ +│ │ TrpcA2aAgentService │ │ +│ │ ┌─────────────────────────────────────┐ │ │ +│ │ │ LlmAgent │ │ │ +│ │ │ (Cancel-enabled Agent) │ │ │ +│ │ └─────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +**Server configuration:** + +run_server.py: + +```python +import uvicorn +from dotenv import load_dotenv + +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig +from trpc_agent_sdk.server.a2a import TrpcA2aAgentService + +load_dotenv() + +HOST = "127.0.0.1" +PORT = 18082 +# Timeout (seconds) for waiting the Agent to complete cancellation, recommended to keep consistent with client timeout +CANCEL_WAIT_TIMEOUT = 3.0 + + +def create_a2a_service() -> TrpcA2aAgentService: + """Create an A2A service with Cancel support""" + from agent.agent import root_agent + + # Key configuration: cancel_wait_timeout controls how long the server waits + # for the backend Agent to complete the cancellation after receiving cancel_task + executor_config = TrpcA2aAgentExecutorConfig( + cancel_wait_timeout=CANCEL_WAIT_TIMEOUT, + ) + + a2a_svc = TrpcA2aAgentService( + service_name="weather_agent_cancel_service", + agent=root_agent, + executor_config=executor_config, + ) + a2a_svc.initialize() + + return a2a_svc + + +def serve(): + """Start the A2A service""" + a2a_svc = create_a2a_service() + + # Assemble the service using a2a-sdk standard components + request_handler = DefaultRequestHandler( + agent_executor=a2a_svc, + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=a2a_svc.agent_card, + http_handler=request_handler, + ) + + uvicorn.run(server.build(), host=HOST, port=PORT) + + +if __name__ == "__main__": + serve() +``` + +**Client usage:** + +test_a2a_cancel.py: + +```python +import asyncio +import uuid +from typing import Awaitable +from typing import Callable +from typing import Optional + +from dotenv import load_dotenv +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.events import AgentCancelledEvent +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.server.a2a import TrpcRemoteA2aAgent +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() + +# A2A server address, must match the configuration in run_server.py +AGENT_BASE_URL = "http://127.0.0.1:18082" +# Client timeout (seconds) for waiting cancellation to complete, recommended to keep consistent with server cancel_wait_timeout +CANCEL_TIMEOUT = 3.0 + + +async def run_remote_agent( + runner: Runner, + user_id: str, + session_id: str, + query: str, + tool_call_callback: Optional[Callable[[], Awaitable[None]]] = None, + event_count_callback: Optional[Callable[[int], Awaitable[None]]] = None, +) -> None: + """Run remote Agent and process the event stream""" + user_content = Content(parts=[Part.from_text(text=query)]) + + run_config = RunConfig(agent_run_config={ + "metadata": { + "user_id": user_id, + }, + }) + + print("🤖 Remote Agent: ", end="", flush=True) + event_count = 0 + try: + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + run_config=run_config, + ): + event_count += 1 + if event_count_callback: + await event_count_callback(event_count) + + # Received cancel event, indicating the Agent was successfully cancelled + if isinstance(event, AgentCancelledEvent): + print(f"\n❌ Run was cancelled: {event.error_message}") + break + + if not event.content or not event.content.parts: + continue + + # Process streaming output (partial=True indicates a streaming chunk) + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + continue + + # Process complete events (tool calls, tool results, etc.) + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + print(f"\n🔧 [Invoke Tool: {part.function_call.name}({part.function_call.args})]") + # Trigger callback when tool call is detected, used to initiate cancellation during tool execution + if tool_call_callback: + await tool_call_callback() + elif part.function_response: + print(f"📊 [Tool Result: {part.function_response.response}]") + + except Exception as e: + print(f"\n⚠️ Error: {e}") + + print() + + +def create_runner( + app_name: str, + session_service: InMemorySessionService, + remote_agent: TrpcRemoteA2aAgent, +) -> Runner: + """Create a Runner instance bound to the remote A2A Agent""" + return Runner(app_name=app_name, agent=remote_agent, session_service=session_service) + + +# ============================================================ +# Scenario 1: Cancel during LLM streaming output +# After receiving 10 streaming events, send a cancel request to the remote service via cancel_run_async +# ============================================================ +async def scenario_1_cancel_during_streaming(remote_agent: TrpcRemoteA2aAgent) -> None: + print("📋 Scenario 1: Cancel During LLM Streaming (Remote A2A)") + print("-" * 80) + + app_name = "a2a_cancel_demo" + user_id = "demo_user" + session_id = str(uuid.uuid4()) + session_service = InMemorySessionService() + + query1 = "Introduce yourself in detail, what can you do as a weather assistant." + print(f"🆔 Session ID: {session_id[:8]}...") + print(f"📝 User Query 1: {query1}") + print() + + event_threshold_reached = asyncio.Event() + + async def on_event_count(count: int) -> None: + # Trigger cancel signal when the 10th event is received + if count == 10: + print(f"\n⏳ [Received {count} events, triggering cancellation...]") + event_threshold_reached.set() + + # Runner for running the Agent + runner1 = create_runner(app_name, session_service, remote_agent) + + async def run_query1() -> None: + await run_remote_agent(runner1, user_id, session_id, query1, event_count_callback=on_event_count) + + # Run Agent in a background task + task = asyncio.create_task(run_query1()) + + print("⏳ Waiting for first 10 events...") + await event_threshold_reached.wait() + + # Use another Runner to send the cancel request (simulating an independent cancel caller) + runner2 = create_runner(app_name, session_service, remote_agent) + print("\n⏸️ Requesting cancellation after 10 events...") + # cancel_run_async sends a cancel_task request to the remote A2A service + success = await runner2.cancel_run_async(user_id=user_id, session_id=session_id, timeout=CANCEL_TIMEOUT) + print(f"✓ Cancellation requested: {success}") + + await task + + print() + print("💡 Result: The partial response was saved to session with cancellation message") + print() + + # Continue conversation in the same session after cancellation to verify session context is maintained + query2 = "what happens?" + print(f"📝 User Query 2: {query2}") + print() + + runner3 = create_runner(app_name, session_service, remote_agent) + await run_remote_agent(runner3, user_id, session_id, query2) + + print("💡 Result: Agent can still respond with session context maintained") + print("-" * 80) + print() + + +# ============================================================ +# Scenario 2: Cancel during tool execution +# Initiate cancellation after detecting a function_call event, while the tool is still executing on the server +# ============================================================ +async def scenario_2_cancel_during_tool_execution(remote_agent: TrpcRemoteA2aAgent) -> None: + print("📋 Scenario 2: Cancel During Tool Execution (Remote A2A)") + print("-" * 80) + + app_name = "a2a_cancel_demo" + user_id = "demo_user" + session_id = str(uuid.uuid4()) + session_service = InMemorySessionService() + + query1 = "What's the current weather in Shanghai and Beijing?" + print(f"🆔 Session ID: {session_id[:8]}...") + print(f"📝 User Query 1: {query1}") + print() + + tool_call_detected = asyncio.Event() + + async def on_tool_call() -> None: + # Set signal when tool call is detected to trigger cancellation + print("⏳ [Tool call detected...]") + tool_call_detected.set() + + runner1 = create_runner(app_name, session_service, remote_agent) + + async def run_query1() -> None: + await run_remote_agent(runner1, user_id, session_id, query1, tool_call_callback=on_tool_call) + + task = asyncio.create_task(run_query1()) + + print("⏳ Waiting for tool call to be detected...") + await tool_call_detected.wait() + + # Initiate cancellation during tool execution; completed tool results are preserved, incomplete calls are cleaned up + runner2 = create_runner(app_name, session_service, remote_agent) + print("\n⏸️ Tool call detected! Requesting cancellation during tool execution...") + success = await runner2.cancel_run_async(user_id=user_id, session_id=session_id, timeout=CANCEL_TIMEOUT) + print(f"✓ Cancellation requested: {success}") + + await task + + print() + print("💡 Result: Incomplete function calls were cleaned up from session") + print() + + # Continue conversation after cancellation to verify session recovery + query2 = "what happens?" + print(f"📝 User Query 2: {query2}") + print() + + runner3 = create_runner(app_name, session_service, remote_agent) + await run_remote_agent(runner3, user_id, session_id, query2) + + print("💡 Result: Agent can still respond with session context maintained") + print("-" * 80) + print() + + +async def main(): + # Create a remote A2A Agent connected to the service started by run_server.py + remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url=AGENT_BASE_URL, + description="Professional weather query assistant with cancel support", + ) + await remote_agent.initialize() + + # Run the two cancel scenarios in sequence + await scenario_1_cancel_during_streaming(remote_agent) + + await scenario_2_cancel_during_tool_execution(remote_agent) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Configuration reference:** + +| Configuration Location | Parameter | Default | Description | +|----------|------|--------|------| +| Server | `cancel_wait_timeout` | 1.0 | Timeout for the server to wait for the backend Agent to complete cancellation | +| Client | `timeout` | 1.0 | Timeout for the client to wait for the local RemoteA2aAgent to complete cancellation | + +It is recommended to configure the same timeout value for both. + +**Full example:** +- [examples/a2a_with_cancel](../../../examples/a2a_with_cancel/README.md) + +### AG-UI + +Agent services deployed via the AG-UI protocol automatically trigger Cancel when the client closes the SSE connection. + +**Architecture:** + +``` +┌─────────────────────────────────────────────────┐ +│ Client │ +│ ┌───────────────────────────────────────────┐ │ +│ │ @ag-ui/client │ │ +│ │ agent.abortRun() closes connection │ │ +│ └─────────────┬─────────────────────────────┘ │ +│ │ AG-UI Protocol (SSE) │ +└────────────────┼────────────────────────────────┘ + │ HTTP + │ ⚡ Connection disconnected + │ +┌────────────────▼────────────────────────────────┐ +│ Server │ +│ ┌───────────────────────────────────────────┐ │ +│ │ AgUiService (detects disconnect) │ │ +│ │ ┌─────────────────────────────────────┐ │ │ +│ │ │ AgUiAgent.cancel_run() │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ Cancellation Manager │ │ │ +│ │ │ (cancel.cancel_run) │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ Agent (stops at checkpoint) │ │ │ +│ │ └─────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +**Server configuration:** + +run_server.py: + +```python +from dotenv import load_dotenv + +from trpc_agent_sdk.sessions import InMemorySessionService + +from _agui_runner import create_agui_runner + +load_dotenv() + +HOST = "127.0.0.1" +PORT = 18080 + +app_name = "agui_cancel_demo" + + +def serve(): + """Start the AG-UI service, register Agent and bind routes""" + service_name = "weather_agent_cancel_service" + uri = "/weather_agent" # AG-UI endpoint path, clients connect via this path + from agent.agent import root_agent + session_service = InMemorySessionService() + agui_runner = create_agui_runner(app_name, + service_name, + uri, + root_agent=root_agent, + session_service=session_service) + agui_runner.run(HOST, PORT) + + +if __name__ == "__main__": + serve() +``` + +_agui_runner.py: + +```python +from contextlib import asynccontextmanager +from typing import Any + +from ag_ui.core import RunAgentInput +from fastapi import FastAPI +from pydantic import BaseModel + +from trpc_agent_sdk.agents import BaseAgent +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 + + +class HealthResponse(BaseModel): + status: str = "ok" + app_name: str + version: str = "1.0.0" + + +class AguiRunner: + """AG-UI Runner: manages AgUiManager, FastAPI app, and service registration""" + + def __init__( + self, + app_name: str, + ) -> None: + self._app_name = app_name + self._agui_manager = AgUiManager() + self._app = self._create_app() + + @property + def app(self) -> FastAPI: + return self._app + + def register_service(self, service_name: str, service: AgUiService) -> None: + self._agui_manager.register_service(service_name, service) + + def run(self, host: str, port: int, **kwargs: Any) -> None: + self._app.get("/health", response_model=HealthResponse, tags=["meta"])(self.health) + self._agui_manager.set_app(self._app) + self._agui_manager.run(host, port, **kwargs) + + @asynccontextmanager + async def _lifespan(self, app: FastAPI): + logger.info("TRPC AG-UI Server (with cancel) starting up.") + yield + logger.info("TRPC AG-UI Server (with cancel) shutting down.") + await self._agui_manager.close() + + def _create_app(self) -> FastAPI: + app = FastAPI( + title="TRPC AG-UI Server (Cancel Demo)", + description="HTTP API for TRPC AG-UI Server with Cancel support", + version="1.0.0", + lifespan=self._lifespan, + ) + return app + + async def health(self) -> HealthResponse: + return HealthResponse(app_name=self._app_name) + + +def _create_agui_agent(name: str, root_agent: BaseAgent, **kwargs) -> AgUiAgent: + """Create AgUiAgent with cancel_wait_timeout configuration""" + agui_agent = AgUiAgent( + trpc_agent=root_agent, + app_name=name, + # Key configuration: timeout for waiting the Agent to complete cancellation after SSE disconnect + # If configured too short, Cancel may not complete and streamed text cannot be saved to the session + cancel_wait_timeout=3.0, + **kwargs, + ) + return agui_agent + + +def create_agui_runner(app_name: str, service_name: str, uri: str, **kwargs: Any) -> AguiRunner: + """Assemble AG-UI service: create Runner -> create Service -> register Agent route""" + ag_ui_runner: AguiRunner = AguiRunner(app_name) + agui_service = AgUiService(service_name, app=ag_ui_runner.app) + agui_agent = _create_agui_agent(app_name, **kwargs) + # Register the Agent to the specified URI path, clients connect via this path + agui_service.add_agent(uri, agui_agent) + ag_ui_runner.register_service(service_name, agui_service) + return ag_ui_runner +``` + +**Client usage (JavaScript):** + +client_js/main.js: + +```javascript +import { HttpAgent } from '@ag-ui/client'; + +// Connect to the AG-UI server, path must match the uri registered in run_server.py +const agent = new HttpAgent({ + url: 'http://127.0.0.1:18080/weather_agent', + debug: false +}); + +let chunkCount = 0; +const ABORT_AFTER_CHUNKS = 5; // Trigger cancellation after receiving 5 text chunks + +// Subscribe to AG-UI event stream +const subscription = agent.subscribe({ + onTextMessageStartEvent: ({ event }) => { + process.stdout.write('\n🤖 Assistant: '); + }, + onTextMessageContentEvent: ({ event }) => { + process.stdout.write(event.delta ?? ''); + chunkCount++; + // After reaching threshold, call abortRun() to close SSE connection, triggering server-side Cancel + if (chunkCount === ABORT_AFTER_CHUNKS) { + process.stdout.write('\n\n⏸️ Aborting run after receiving ' + ABORT_AFTER_CHUNKS + ' text chunks...\n'); + agent.abortRun(); + } + }, + onTextMessageEndEvent: ({ event }) => { + process.stdout.write('\n'); + }, + onToolCallStartEvent: ({ event }) => { + process.stdout.write(`\n🔧 Call Tool ${event.toolCallName}: `); + }, + onToolCallArgsEvent: ({ event }) => { + process.stdout.write(event.delta ?? ''); + }, + onToolCallResultEvent: ({ event }) => { + process.stdout.write(`\n✅ Tool result: ${event.content}`); + }, + onRunStartedEvent: ({ event }) => { + process.stdout.write(`\n⚙️ Run started: ${event.runId}`); + }, + onRunFinishedEvent: ({ result }) => { + if (result !== undefined) { + process.stdout.write(`⚙️ Run finished, result: ${result}\n`); + } else { + process.stdout.write('⚙️ Run finished\n'); + } + }, + onRunFailedEvent: ({ error }) => { + process.stdout.write(`❌ Run failed: ${error}\n`); + } +}); + +// Send user message and start the Agent +await agent.addMessage({ + role: 'user', + content: 'Please introduce yourself in detail and tell me what you can do.', + id: 'user_123' +}); + +await agent.runAgent(); + +subscription.unsubscribe?.(); +``` + +**Cancel trigger mechanism:** +- Client calls `agent.abortRun()` to close the SSE connection +- Server detects the disconnection (`asyncio.CancelledError`) +- Automatically invokes `cancel_run()` to trigger cooperative cancellation +- Agent stops execution at the checkpoint +- Partial responses and session state are saved + +**Configuration reference:** + +| Parameter | Default | Description | +|------|--------|------| +| `cancel_wait_timeout` | 3.0 | Timeout (in seconds) for waiting the Cancel operation to complete. If this value is not properly configured, the Cancel operation may fail to execute successfully, causing streamed text to not be saved to the session. | + +**Full example:** +- [examples/agui_with_cancel](../../../examples/agui_with_cancel/README.md) diff --git a/docs/mkdocs/en/claude_agent.md b/docs/mkdocs/en/claude_agent.md new file mode 100644 index 000000000..cc73c7f82 --- /dev/null +++ b/docs/mkdocs/en/claude_agent.md @@ -0,0 +1,716 @@ +# tRPC-Agent ClaudeAgent + +After the release of [Claude-Code](https://www.claude.com/product/claude-code), due to its **outstanding task planning capabilities**, developers have increasingly attempted to build Agents tailored to their business needs based on this CLI tool. Anthropic also officially released [Claude-Agent-Sdk-Python](https://github.com/anthropics/claude-agent-sdk-python) to integrate with the Claude-Code-CLI tool, enabling rapid Agent development without complex Agent workflow orchestration or repeated prompt tuning. With simple tool configuration and system_instruction writing, you can achieve solid results — the Agent will continuously plan appropriate tool calls to accomplish given tasks. + +tRPC-Agent integrates Claude-Agent-Sdk-Python to bridge the tRPC-Agent framework ecosystem with Claude-Code-CLI, making it easy for businesses to migrate existing Agents developed with Claude-Agent-Sdk-Python and reuse the framework's complete ecosystem (including but not limited to internal model integration, tRPC ecosystem, and Agent AI ecosystem). It also provides current framework users with an alternative approach for developing Agents. + +## Use Cases + +The following scenarios are well-suited for using ClaudeAgent: +1. Code-related Agents: Claude-Code is inherently designed for code generation. By introducing domain knowledge through additional tools, it can write code or reuse Claude-Code's code retrieval tools; +2. Agents requiring file system interaction: Claude-Code has built-in file system read/write operations and supports file search tools, which Agents can directly leverage; +3. Agents for complex tasks: Claude-Code's built-in multi-Agent system architecture and fine-tuned prompts enable step-by-step planning for complex tasks, making it suitable for scenarios where simple configuration can accomplish complex tasks. + +Claude-Code also includes the following built-in tools. If your Agent happens to use these tools, consider trying ClaudeAgent to see if it provides improvements in your scenario: + +| Tool | Description | +|------|------| +| Bash | Execute shell commands in the environment | +| Edit | Make precise edits to specific files | +| Glob | Find files based on pattern matching | +| Grep | Search for patterns in file contents | +| NotebookEdit | Modify Jupyter notebook cells | +| Read | Read file contents | +| SlashCommand | Run custom slash commands | +| Task | Run sub-Agents to handle complex multi-step tasks | +| TodoWrite | Create and manage structured task lists | +| WebFetch | Fetch content from a specified URL | +| WebSearch | Perform web searches with domain filtering | +| Write | Create or overwrite files | + +**Note:** +- **Claude-Code's implementation is closed-source. If your business scenario requires fine-grained optimization or flow control over the underlying Agent, please use it with caution.** + +## Design + +As shown in the architecture diagram below, tRPC-Agent provides ClaudeAgent and Anthropic Proxy Server to integrate this capability. ClaudeAgent is implemented based on Claude-Agent-SDK-Python, and the Anthropic Proxy Server forwards Claude-Code requests to connect with internal models. The core components are described as follows: +- **ClaudeAgent**: Users develop Claude-Code-based Agents by configuring the **ClaudeAgent provided by the tRPC-Agent-Python framework**. ClaudeAgent can be configured with different Session modes — either letting Claude-Code manage sessions (default), or letting tRPC-Agent manage sessions (by setting ClaudeAgent's `enable_session: True` field). +- **SessionManager - Claude Session**: **Enabled by default**. Sessions are managed by Claude-Code. If your business requires multi-node deployment, please use `hash` routing, as each Session will create a new Claude-Code-Process due to Claude-SDK limitations. +- **Directly Use - tRPC Session**: **Disabled by default**. Sessions are managed by tRPC-Agent. For multi-node deployment, you only need to use the framework's RedisSession. Essentially, each call to Claude-Code is a brand new conversation, except the framework injects historical messages into the conversation. Since the conversation is not managed by Claude-Code, some internal reasoning information is missing, so multi-turn conversation performance may be inferior to Claude Session in scenarios that depend on internal reasoning information. +- **Claude Code Process**: A process is spawned by the Claude-Agent-Python-SDK, interacting with Claude-Code-CLI via stdio. Each ClaudeSession manages the interaction with one subprocess. +- **Tools**: When configuring an Agent, users can use both Claude-Code's built-in tools and custom tools. The framework automatically injects them into the CLI. +- **Model**: Like LlmAgent, users can freely define the model used by the Agent. When Claude-Code-CLI executes, it will call this model. Any model compatible with the framework can be configured. +- **Anthropic Proxy Process**: The framework automatically spawns this proxy subprocess to forward Claude-Code-CLI requests to internal model services. Businesses can freely configure models from Venus, Hunyuan, Tencent Cloud, etc. Note the `Default Claude Request` section — even if a model is configured for ClaudeAgent, not all model calls during CLI execution will use the configured model. Some internal processes and simple calls will use the default models (i.e., claude-opus, claude-haiku, claude-sonnet). The framework provides a mechanism in the Proxy process to forward these default model calls to the user-configured model. + +

+ ClaudeAgent's Architecture +

+ +## Usage Guide + +### Installation + +Before using ClaudeAgent, please install the Claude-Code-CLI tool in your environment: +```bash +npm install -g @anthropic-ai/claude-code +``` + +Then install the tRPC-Agent extension package for ClaudeAgent: +```bash +pip install -e ".[agent-claude]" +``` + +### Usage + +The following example demonstrates the usage by developing a code generation Agent. For the complete example, see: [examples/claude_agent_with_code_writer/run_agent.py](../../../examples/claude_agent_with_code_writer/run_agent.py). + +The project structure of this example is as follows: +``` +examples/claude_agent_with_code_writer/ +├── .env # Environment variable configuration +├── run_agent.py # Main entry point +└── agent/ + ├── __init__.py + ├── config.py # Model configuration + ├── prompts.py # Prompt configuration + └── agent.py # Agent creation and environment management +``` + +First, retrieve model configuration from environment variables in `agent/config.py`, and define the Agent's instructions in `agent/prompts.py`: +```python +import os +# agent/config.py +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') # Model API key + url = os.getenv('TRPC_AGENT_BASE_URL', '') # Model service URL + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') # Model name + if not api_key or not url or not model_name: + raise ValueError('''TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, + and TRPC_AGENT_MODEL_NAME must be set in environment variables''') + return api_key, url, model_name +``` + +```python +# agent/prompts.py +INSTRUCTION = "You are a helpful assistant for writing code." +``` + +Then, configure ClaudeAgent in `agent/agent.py`. Since we are developing a code generation assistant, we only need to specify its role and the tools it uses. As you can see, we specify that it can operate files (Read/Write/Edit), search filenames (Glob) and file contents (Grep), and supports task management (TodoWrite). In addition to Claude-Code's built-in tools, other tools can also be configured via `tools`. If you are unsure how to configure them, refer to [tRPC-Agent FunctionTools Usage](./tool.md) and [tRPC-Agent MCPTools Usage](./tool.md): + +```python +# agent/agent.py +from trpc_agent_sdk.server.agents.claude import ClaudeAgent, setup_claude_env, destroy_claude_env +from trpc_agent_sdk.models import LLMModel, OpenAIModel +from claude_agent_sdk.types import ClaudeAgentOptions + +from .prompts import INSTRUCTION +from .config import get_model_config + +CLAUDE_ALLOWED_TOOLS = ["Read", "Write", "Edit", "TodoWrite", "Glob", "Grep"] + +def _create_model() -> LLMModel: + """Create a model""" + api_key, url, model_name = get_model_config() + return OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + +def create_agent() -> ClaudeAgent: + """Create an agent""" + return ClaudeAgent( + name="code_writing_agent", # Agent name + description="A helpful Claude assistant for writing code", # Agent description + model=_create_model(), # LLM model to use + instruction=INSTRUCTION, # Agent system instruction + claude_agent_options=ClaudeAgentOptions( + allowed_tools=CLAUDE_ALLOWED_TOOLS, # Claude-Code built-in tool allowlist + ), + # tools=[...], # Other custom business tools can be placed here, see link for details + # enable_session=False, # Whether to enable tRPC Session, disabled by default, see architecture design for details + ) +``` + +Next, provide environment initialization and cleanup methods in the same file. When the process starts, before executing the Agent, you need to initialize the Proxy subprocess and Claude's default model via `setup_claude_env`, and stop the Proxy subprocess via `destroy_claude_env`: + +```python +def setup_claude(proxy_host: str = "0.0.0.0", proxy_port: int = 8082): + """Setup Claude environment (proxy server)""" + claude_default_model = _create_model() + setup_claude_env( + proxy_host=proxy_host, + proxy_port=proxy_port, + claude_models={"all": claude_default_model}, + ) + +def cleanup_claude(): + """Clean up Claude environment (stop proxy server)""" + destroy_claude_env() +``` +In `run_agent.py`, implement the main flow for running the Agent. Initialize the Runtime to execute Claude-Agent-Python-SDK via `agent.initialize()`, run the Agent through Runner, and print out the Agent's various actions. Before the program exits, stop the Claude-Code session via `agent.destroy()`: +```python +# run_agent.py +import asyncio +import uuid +import json + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + +from dotenv import load_dotenv +load_dotenv() + + +async def run_code_writer_agent(): + """Run the Claude code writer agent demo""" + + app_name = "claude_code_writing_app" + + from agent.agent import create_agent, setup_claude, cleanup_claude + + # Initialize Claude environment: start the Anthropic Proxy Server subprocess + setup_claude() + + # Create Agent and initialize runtime + agent = create_agent() + agent.initialize() + + # Create in-memory session service and Runner + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=agent, session_service=session_service) + + user_id = "demo_user" + + demo_queries = [ + "Write a Python function that calculates the Fibonacci sequence up to n terms, save it to 'fibonacci.py'.", + ] + + try: + for query in demo_queries: + current_session_id = str(uuid.uuid4()) + + await session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=current_session_id, + state={"user_name": f"{user_id}"}, + ) + + print(f"🆔 Session ID: {current_session_id[:8]}...") + print(f"📝 User: {query}") + + user_content = Content(parts=[Part.from_text(text=query)]) + + print("🤖 Assistant: ", end="", flush=True) + # Asynchronously iterate over the event stream returned by the Agent + async for event in runner.run_async(user_id=user_id, session_id=current_session_id, new_message=user_content): + if not event.content or not event.content.parts: + continue + + # Streaming text fragment (partial=True), print character by character + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + continue + + # Complete events: tool calls, tool results, final responses, etc. + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + args_str = json.dumps(part.function_call.args, ensure_ascii=False)[:200] + print(f"\n🔧 [Tool Call: {part.function_call.name}({args_str})]", flush=True) + elif part.function_response: + response_str = json.dumps(part.function_response.response, ensure_ascii=False)[:200] + print(f"📊 [Tool Result: {part.function_response.name}({response_str})]", flush=True) + + print("\n" + "-" * 40) + + finally: + # Resource cleanup: close Runner -> destroy Agent (stop Runtime) -> stop Proxy subprocess + await runner.close() + agent.destroy() + cleanup_claude() + print("🧹 Claude environment cleaned up") + + +if __name__ == "__main__": + asyncio.run(run_code_writer_agent()) +``` + +### Running the Agent + +Before running, please set the model-related environment variables (or configure them in the `.env` file): +```bash +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +Run the Agent program. Example output is shown below. As you can see, ClaudeAgent automatically writes code and saves it to a file based on user instructions. You can replace the query text in `demo_queries` with examples suitable for your scenario. For more examples, refer to the [trpc-agent examples directory](../../../examples/): + +```text +[2026-03-17 17:18:47][INFO][trpc_agent][_setup.py:222][68046] Proxy server proxy process started (PID: 68077) +[2026-03-17 17:18:49][INFO][trpc_agent][_setup.py:239][68046] Proxy server is ready at http://0.0.0.0:8082 +[2026-03-17 17:18:49][INFO][trpc_agent][_runtime.py:26][68046] ClaudeAgent event loop thread started +🆔 Session ID: 3fe4f9f2... +📝 User: Write a Python function that calculates the Fibonacci sequence up to n terms, save it to 'fibonacci.py'. +🤖 Assistant: Here is the Python function that calculates the Fibonacci sequence up to n terms. +I will save it to a file named fibonacci.py: +🔧 [Tool Call: Write({"file_path": "fibonacci.py", "content": "def fibonacci(n):\n ..."})] +📊 [Tool Result: Write({"result": "File created successfully at: fibonacci.py"})] +I've created the fibonacci.py file with the Fibonacci sequence implementation. +---------------------------------------- +[2026-03-17 17:19:14][INFO][trpc_agent][_runtime.py:38][68046] ClaudeAgent event loop thread stopped +[2026-03-17 17:19:14][INFO][trpc_agent][_runtime.py:61][68046] ClaudeAgent thread terminated successfully +[2026-03-17 17:19:14][INFO][trpc_agent][_setup.py:275][68046] Terminating proxy process (PID: 68077)... +[2026-03-17 17:19:14][INFO][trpc_agent][_setup.py:287][68046] Subprocess terminated successfully. +🧹 Claude environment cleaned up +``` + +Note that when the program is running, an `anthropic_proxy.log` file will also be written to the working directory. This is the log file of the subprocess that forwards Claude-Code requests. You can review it if interested — it shows the model invocation behavior triggered by Claude-Code. + +## Event Mapping + +ClaudeAgent receives messages from Claude-Code through `claude_agent_sdk` and converts them into the framework's unified `Event` objects. The mapping between SDK message types and framework events is as follows: + +| Claude SDK Message Type | Framework Event | Description | +|-----|-----|-----| +| `TextBlock` in `AssistantMessage` | Text response event | Text content of the model response | +| `ThinkingBlock` in `AssistantMessage` | Thought event | Model reasoning/thinking content | +| `ToolUseBlock` in `AssistantMessage` | Tool-call response event | Tool invocation, including tool name and parameters | +| `ToolResultBlock` in `AssistantMessage` / `UserMessage` | Tool-result response event | Tool execution result | +| `StreamEvent` (`text_delta`) | Partial text event | Streaming text fragment | +| `StreamEvent` (`input_json_delta`) | Partial tool-call event | Streaming tool parameter fragment | +| `SystemMessage` | No event emitted | Logged only | +| `ResultMessage` | No event emitted | Contains usage and duration statistics, logged only | + +**Final response determination**: When an event contains no tool calls, no tool results, and is not a partial event, the framework determines it as a final response (`is_final_response()`). At this point, if `output_key` is configured, the text result will be written to the session state. + +## Streaming Output + +ClaudeAgent supports streaming output, controlled by `run_config.streaming` in Runner: + +```python +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.configs import RunConfig + +runner = Runner(app_name="my_app", agent=agent, session_service=session_service) + +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + run_config=RunConfig(streaming=True), # Enable streaming output +): + if event.partial: + # Streaming text fragment + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + else: + # Complete events (tool calls, tool results, final responses, etc.) + ... +``` + +When enabled, ClaudeAgent sets `ClaudeAgentOptions.include_partial_messages = True`, and the SDK returns `StreamEvent` type streaming messages, which the framework converts into events with `partial=True`. + +Streaming output supports two types of content: +- **Text stream**: `text_delta` type, each fragment is emitted as a partial text event +- **Tool parameter stream**: `input_json_delta` type, only effective for tools marked with `is_streaming=True`, parameter fragments are emitted via partial tool-call events + +## Observability and Tracing + +### Event Tracing + +ClaudeAgent has a built-in `CustomTraceReporter` that automatically performs trace reporting when each event is emitted: + +```python +trace_reporter = CustomTraceReporter( + agent_name=self.name, # Agent name, used to identify the trace source + model_prefix="claude", # Model trace prefix, distinguishes different types of model calls + tool_description_prefix="Claude tool", # Tool trace prefix, marks Claude built-in tools + text_content_filter=_text_filter, # Text filter, used for desensitization or truncation of excessively long text content +) +# Automatically traces each emitted event +trace_reporter.trace_event(ctx, event) +``` + +ClaudeAgent interacts with claude_agent_sdk, which returns a `ResultMessage` after each query is completed, containing statistics for this call (conversation turns, duration, cost, etc.). The framework does not convert it into an event but records it as a debug log: +``` +Claude query complete: turns=5, duration=12000ms, cost=$0.05 +``` + +### Proxy Logs + +When running ClaudeAgent, the framework generates an `anthropic_proxy.log` file in the working directory, recording the logs of the Anthropic Proxy Server forwarding requests. This can be used to observe Claude-Code's model invocation behavior and troubleshoot issues in the model call chain. + +## Advanced Usage +### Tool Configuration + +#### Using Claude-Code Built-in Tools + +Specify the built-in tools allowed for use via the `allowed_tools` field in the `claude_agent_options` parameter: + +```python +from claude_agent_sdk.types import ClaudeAgentOptions + +agent = ClaudeAgent( + name="code_writer", + description="A helpful Claude assistant for writing code", + model=model, + instruction="You are a helpful assistant for writing code.", + claude_agent_options=ClaudeAgentOptions( + # Specify allowed Claude-Code built-in tools + allowed_tools=["Read", "Write", "Edit", "TodoWrite", "Glob", "Grep"], + ), +) +``` + +#### Using Custom Tools + +ClaudeAgent supports configuring Agent framework tools, including FunctionTool and MCPTool: + +```python +import datetime +from mcp import StdioServerParameters + +from trpc_agent_sdk.server.agents.claude import ClaudeAgent +from claude_agent_sdk.types import ClaudeAgentOptions +from trpc_agent_sdk.tools import FunctionTool, MCPToolset, StdioConnectionParams + +def get_current_date(): + """Get today's date""" + return datetime.datetime.now().strftime("%Y-%m-%d") + +# Custom MCP toolset +class GoogleSearchMCP(MCPToolset): + def __init__(self): + super().__init__() + self._connection_params = StdioConnectionParams( + server_params=StdioServerParameters( + command="npx", + args=["google-search-mcp"], + ), + timeout=30.0, + ) + +agent = ClaudeAgent( + name="travel_planner", + description="Travel planning assistant", + model=model, + instruction="You are a travel planning assistant...", + claude_agent_options=ClaudeAgentOptions( + allowed_tools=["TodoWrite"], # Claude built-in tools + ), + tools=[ # User-defined custom tools + FunctionTool(get_current_date), + GoogleSearchMCP(), + ], +) +``` + +### Session Management + +ClaudeAgent provides two session management modes, controlled by the `enable_session` parameter: + +#### Mode 1: Claude Session (Default, `enable_session=False`) + +Sessions are managed internally by Claude-Code. The framework uses `ctx.session.id` (tRPC-Agent's session ID) as Claude's session ID. The same session ID corresponds to the same `ClaudeSDKClient` instance and Claude-Code subprocess. + +In this mode, Claude-Code retains the complete internal reasoning context, providing the best multi-turn conversation performance. Only the latest user message is sent each time, with history maintained by Claude-Code. + +Suitable for single-node deployment or multi-node deployment with hash routing. + +#### Mode 2: tRPC Session (`enable_session=True`) + +Sessions are managed by tRPC-Agent. Each call creates a new `ClaudeSDKClient`, with the session ID fixed as `"default"`, meaning each call is a brand new Claude-Code session. The framework extracts complete conversation history from session events and sends it as a prompt with context. + +In this mode, the framework's RedisSession and other distributed session storage options can be used, making it suitable for multi-node deployment. However, since Claude-Code does not retain internal reasoning information, multi-turn conversation performance may be inferior to Claude Session. + +#### Session Lifecycle + +When using Claude Session, the framework manages the lifecycle of `ClaudeSDKClient` instances through `SessionManager`, with behavior controlled by `SessionConfig`: +- `ttl`: Time before idle sessions are cleaned up, in seconds. Defaults to 600s (10 minutes) of inactivity before cleanup. Set to 0 to disable automatic cleanup. + +Before each query, `SessionManager` automatically cleans up idle sessions that have exceeded the TTL, releasing the corresponding Claude-Code subprocess resources. + +```python +from trpc_agent_sdk.server.agents.claude import SessionConfig + +ClaudeAgent( + ..., + # enable_session=False, # Disabled by default, meaning Claude Session is used, and the SessionConfig below takes effect + session_config=SessionConfig( + ttl=600, + ), +) +``` + +#### Resource Cleanup + +When using ClaudeAgent, resources must be properly cleaned up before the program exits: +- `agent.destroy()`: Closes the SessionManager and all ClaudeSDKClient connections, stops the AsyncRuntime thread +- `destroy_claude_env()`: Stops the Anthropic Proxy Server subprocess + +If cleanup is not performed properly (e.g., force quit with Ctrl+C), residual Claude-Code sessions may interfere with subsequent runs. In this case, manually execute `rm -rf ~/.claude*` to clean up. + +### Using the Built-in Skill Capability of Claude Agent SDK +The Claude Agent SDK has a built-in skill capability. With simple configuration, you can quickly leverage the skill feature. + +#### Creating Skills +- Create a `./claude/skills` directory in the project directory or the home directory (~) + - Generally, if your skills are created in the home directory, they represent user-level skill capabilities (cross-project) + - If your skills directory is created in the project directory, they represent project-level skill capabilities (project-specific) +- Create a skill directory under the skills directory, e.g., traver-helper +- Create a SKILL.md document in the skill directory. Refer to [skill format](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) for the format reference + +Example skill.md +``` +--- +name: Travel Planning Assistant +description: Automatically generates a complete travel plan based on user's travel requirements (destination, time, budget, etc.), including transportation, accommodation, attractions, food, and itinerary. Use when users ask about travel plans, itinerary arrangements, travel guides, or mention traveling to a specific destination. +--- + +# Travel Planning Assistant + +## Workflow + +When a user makes a travel planning request, follow these steps to automatically generate a complete travel plan: +... +``` + +#### Configuring Options +```python +from claude_agent_sdk.types import ClaudeAgentOptions +from trpc_agent_sdk.server.agents.claude import ClaudeAgent + +agent = ClaudeAgent( + name="travel_planner", + description="Travel planning assistant", + model=model, + instruction=""" +You are a professional AI assistant built on the Claude Agent SDK. Your core responsibility is to understand user requirements and invoke the appropriate Skill to complete complex tasks. +You should maintain a professional and objective attitude, and refuse to perform any harmful or non-compliant operations. +""", + claude_agent_options=ClaudeAgentOptions( + # cwd is where the project directory is + cwd="your project path", + # setting_sources is the way of claude agent to get the skills from the user and the project + # user is the way of claude agent to get the skills from path: ~/.claude/skills + # project is the way of claude agent to get the skills from path: cwd/.claude/skills + setting_sources=["user", "project"], + # Skill Tool is the way of claude agent to use the skills,must be allowed + allowed_tools=["Skill"], + ), +) +``` + +- Configure cwd. cwd is the project directory where the Claude Agent operates, which may contain project-level skill documents +- Configure setting_sources. Multiple data sources can be configured. + - If `user` is set, it reads the directory `~/.claude/skills` + - If `project` is set, it reads the directory `cwd_path/.claude/skills` + - Multiple data sources can be set +- Configure tools. `Skill` must be configured as one of the Tool capabilities, because the Claude Agent SDK implements the skill capability through tool invocation. + +For detailed usage instructions, see: [claude agent sdk with skills](https://platform.claude.com/docs/en/agent-sdk/skills) + +#### Test Results + +``` +📝 User: Help me create a travel guide for Beijing + +🤖 Agent: +🔧 [Tool Call: Skill({"skill": "traver_helper"})] +📊 [Tool Result: Skill({"result": "Launching skill: traver_helper"})] + +🔧 [Tool Call: mcp__travel_planner_tools__get_current_date({})] +📊 [Tool Result: mcp__travel_planner_tools__get_current_date({"result": "2025-12-22"})] +### Beijing Travel Guide (3 Days, 2 Nights) + +📍 **Destination**: Beijing +🗓️ **Recommended Travel Date**: December 24, 2025 - December 26, 2025 +⏱️ **Duration**: 3 days, 2 nights +💰 **Budget Range**: Budget (approx. ¥1,500/person) / Comfort (approx. ¥2,500/person) +🎯 **Itinerary Theme**: Cultural Exploration + Food Experience + +--- + +### **B. Transportation Plan** + +#### Round-Trip Transportation +- **High-speed Rail**: Shanghai to Beijing, approx. 4.5 hours, ticket price approx. ¥553 (second class). +- **Flight**: Shanghai to Beijing, approx. 2 hours, ticket price approx. ¥800 (economy class). + +#### Local Transportation +- **Subway**: Beijing subway has extensive coverage, single fare ¥3-7. +- **Bus**: Fare starting from ¥2, suitable for short trips. +- **Taxi**: Starting fare ¥13, suitable for nighttime or group travel. + +**Transportation Tips**: +- High-speed rail is more suitable for short trips; flights are suitable for travelers with tight schedules. +- Download the "Beijing Subway" app in advance for convenient route queries. + +--- + +### **C. Accommodation Recommendations** + +| Hotel Name | Location Advantage | Price Range (per night) | Booking Tips | +|----------------|------------------------|------------------|-------------------| +| Home Inn (Qianmen) | Close to Tiananmen, Forbidden City | ¥300-400 | Book 1 week in advance | +| Beijing Hotel | City center, convenient transportation | ¥800-1,000 | Book 2 weeks in advance | +| Courtyard Guesthouse | Experience old Beijing charm | ¥500-700 | Book 1 month in advance | + +..... +``` + +### ClaudeAgentOptions Configuration + +You can fine-tune Claude-Code's behavior through `ClaudeAgentOptions`. Below are some commonly used configurations that can be applied as needed: + +```python +from claude_agent_sdk.types import ClaudeAgentOptions + +claude_agent_options = ClaudeAgentOptions( + # Tool configuration + allowed_tools=["Read", "Write", "Edit"], # Allowed built-in tool list; defaults to all tools if not specified + disallowed_tools=["Bash"], # Disabled tool list + + # Permission control + permission_mode="default", # Permission mode: default, acceptEdits, plan, bypassPermissions + + # Session control + max_turns=10, # Maximum conversation turns + + # Environment configuration + cwd="/path/to/workdir", # Working directory, defaults to the directory of the current main method + add_dirs=["/path/to/extra/dir"], # Additional directories allowed for access + env={"KEY": "VALUE"}, # Environment variables +) +``` + +For detailed parameter descriptions, see: [ClaudeAgentOptions Configuration](https://docs.claude.com/en/api/agent-sdk/python#claudeagentoptions). + +### Model Generation Parameter Configuration + +ClaudeAgent supports configuring model generation parameters (such as temperature, max tokens, etc.) through the `generate_content_config` parameter. This configuration will override the parameters in requests sent by Claude-Code. + +#### Configuring in ClaudeAgent + +You can specify `generate_content_config` when creating a ClaudeAgent: + +```python +import os + +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.server.agents.claude import ClaudeAgent + +# Create generation configuration +config = GenerateContentConfig( + temperature=0.7, # Temperature parameter, controls randomness + max_output_tokens=2000, # Maximum output token count + top_p=0.9, # Nucleus sampling parameter + top_k=40, # Top-k sampling parameter +) + +# Specify configuration when creating the Agent +agent = ClaudeAgent( + name="my_agent", + model=OpenAIModel( + model_name="deepseek-chat", + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url="https://api.deepseek.com/v1", + ), + instruction="You are a helpful assistant.", + generate_content_config=config, # Specify generation configuration +) +``` + +#### Configuring Default Model Parameters in setup_claude_env + +When configuring default models in `setup_claude_env` (for handling Claude-Code's internal calls), you can also configure generation parameters for these default models. + +```python +import os + +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.server.agents.claude import setup_claude_env + +# Configure generate_content_config directly in OpenAIModel +# OpenAIModel has built-in support for the generate_content_config field +model = OpenAIModel( + model_name="deepseek-chat", + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url="https://api.deepseek.com/v1", + generate_content_config=GenerateContentConfig( + temperature=0.8, + max_output_tokens=1500, + ), +) + +# Use the configured model in setup_claude_env +# The Proxy server automatically extracts the model's generate_content_config +# This way, Claude-Code's default calls (sonnet/opus/haiku) will all use this configuration +setup_claude_env( + proxy_host="0.0.0.0", + proxy_port=8082, + claude_models={"all": model} # Configuration is automatically extracted +) +``` + +**Notes**: +- The `generate_content_config` field of `OpenAIModel` is **automatically extracted** in `setup_claude_env` +- The extracted configuration is stored in the Proxy server and applied to all requests mapped to that model +- When using `{"all": model}`, all three models (sonnet, opus, haiku) will use the same configuration + +#### Configuration Lookup and Priority + +The Proxy server looks up configuration in the following order when building requests: + +**Configuration lookup order**: +1. **Configuration stored in model_configs**: Configuration automatically extracted from ClaudeAgent's `generate_content_config` or `setup_claude_env` +2. **Model instance configuration**: If not found in model_configs, the model instance's `generate_content_config` is used (fallback mechanism) +3. **Request parameters**: If the configuration is None or does not exist, the parameters from the Claude-Code request are used + +**Field priority**: For the found configuration, the priority of specific fields is: +- **Configured fields take precedence**: If a field is set (non-None) in `generate_content_config`, the configured value is used +- **Request parameters as fallback**: If a field in the configuration is None, the parameter value from the Claude-Code request is used + +For example: +```python +# Configuration sets temperature=0.7, but top_p is not set (None) +config = GenerateContentConfig(temperature=0.7, top_p=None) + +# Claude-Code request: temperature=0.5, top_p=0.9 +# Final values used: +# temperature=0.7 (configured value is used, not overridden by Claude-Code request parameters) +# top_p=0.9 (configuration is None, Claude-Code request parameter is used) +``` + +**Configuration source priority**: +- Explicit ClaudeAgent configuration > OpenAIModel configuration > Claude request parameters + +#### Notes + +- Commonly configurable fields include: + - `temperature`: Temperature parameter (0.0-1.0) + - `max_output_tokens`: Maximum output token count + - `top_p`: Nucleus sampling parameter + - `top_k`: Top-k sampling parameter + - `stop_sequences`: List of stop sequences + +## Complete ClaudeAgent Examples + +- Simple weather query Agent example: [examples/claude_agent/run_agent.py](../../../examples/claude_agent/run_agent.py) +- Code generation Agent example: [examples/claude_agent_with_code_writer/run_agent.py](../../../examples/claude_agent_with_code_writer/run_agent.py) +- Travel planning Agent (multi-turn conversation) example: [examples/claude_agent_with_travel_planner/run_agent.py](../../../examples/claude_agent_with_travel_planner/run_agent.py) +- Travel planning Agent (Skill) example: [examples/claude_agent_with_skills/run_agent.py](../../../examples/claude_agent_with_skills/run_agent.py) +- Streaming tool Agent example: [examples/claude_agent_with_streaming_tool/run_agent.py](../../../examples/claude_agent_with_streaming_tool/run_agent.py) +- Cancel execution Agent example: [examples/claude_agent_with_cancel/run_agent.py](../../../examples/claude_agent_with_cancel/run_agent.py) + +## FAQ +### Historical Sessions Interfering with Current Session +- Cause: During testing, exiting with Ctrl+C or not following the framework's resource cleanup logic to clean up resources. Since Claude-Code historical sessions were not properly closed, they interfere with the current session. +- Solution: Execute the command: `rm -rf ~/.claude*` to manually clean up sessions. diff --git a/docs/mkdocs/en/code_executor.md b/docs/mkdocs/en/code_executor.md new file mode 100644 index 000000000..a41eef25d --- /dev/null +++ b/docs/mkdocs/en/code_executor.md @@ -0,0 +1,548 @@ +# Agent Code Executor + +To provide Agents with a high degree of flexibility, there are times when an Agent needs to generate and execute code. The tRPC-Agent-Python framework supports CodeExecutor for this scenario. + +When this feature is enabled, if the LLM returns text containing code snippets, the framework will invoke the corresponding CodeExecutor to execute the code and return the execution results to the LLM, which can then continue generating responses based on those results. + +## Code Executor Types + +Three types of code executors are currently available: + +### UnsafeLocalCodeExecutor + +**Features:** +- Executes LLM-generated code within the Agent's own process +- Non-sandboxed environment, directly uses the local Python/Bash runtime; currently only supports `Python/Bash` +- Fast execution speed, no Docker environment required +- **Security Warning**: LLM-generated code may pose risks and is not suitable for production environments + +**Use Cases:** +- Development and testing environments +- Trusted code execution scenarios +- Scenarios requiring rapid iteration and debugging + +### ContainerCodeExecutor + +**Features:** +- Agent dispatches code snippets to a Docker container for execution; currently only supports `Python/Bash` +- Sandboxed environment, providing better isolation and security +- Supports custom Docker images or Dockerfiles +- Requires Docker environment + +**Use Cases:** +- Production environments +- Scenarios requiring execution of untrusted code +- Scenarios requiring environment isolation + +### CubeCodeExecutor + +**Features:** +- Agent dispatches code snippets to a remote Cube/E2B sandbox for execution; supports `Python/Bash` +- Strong sandboxed environment running on a remote host, suitable for executing untrusted code at scale +- Decoupled lifecycle: the same sandbox can be re-attached across processes via `sandbox_id` (`create` / `attach` / `create_or_recreate` factories) +- Ships an optional `CubeWorkspaceRuntime` that adds per-execution workspace directories, file upload/download (single files or whole directories via tar), and structured program runs — useful for the Skill subsystem +- Requires the optional `[cube]` extra (`pip install 'trpc-agent-py[cube]'`, which installs `e2b-code-interpreter`) and access to a Cube/E2B-compatible gateway + +**Use Cases:** +- Production environments where Docker is not available on the agent host +- Scenarios requiring strong remote isolation for untrusted code +- Long-lived skill/code execution that needs a persistent workspace surviving across multiple `execute_code` calls +- Multi-tenant agent platforms that share a remote sandbox fleet + +## Usage Examples + +When creating an LlmAgent, build a CodeExecutor and configure the `code_executor` parameter to enable code execution functionality. + + +### Building a CodeExecutor + +```python +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.code_executors import BaseCodeExecutor +from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor +from trpc_agent_sdk.code_executors import ContainerCodeExecutor +# Cube is an optional extra (`pip install 'trpc-agent-py[cube]'`) +from trpc_agent_sdk.code_executors.cube import CubeCodeExecutor +from trpc_agent_sdk.code_executors.cube import CubeCodeExecutorConfig +from trpc_agent_sdk.log import logger + +async def _create_code_executor(code_executor_type: str = "unsafe_local") -> BaseCodeExecutor: + """Create a code executor. + + Args: + code_executor_type: Type of code executor to use. Options: + - "unsafe_local": Use UnsafeLocalCodeExecutor (default, no Docker required) + - "container": Use ContainerCodeExecutor (requires Docker) + - "cube": Use CubeCodeExecutor (requires the [cube] extra and a Cube/E2B gateway) + - None: Auto-detect from environment variable CODE_EXECUTOR_TYPE, + or default to "unsafe_local" + + Returns: + BaseCodeExecutor instance. + + Raises: + RuntimeError: If container type is requested but Docker is not available. + The error message will include detailed instructions on how to fix the issue. + """ + # Get executor type from environment variable if not specified + if code_executor_type == "unsafe_local": + return UnsafeLocalCodeExecutor(timeout=10) + elif code_executor_type == "container": + # ContainerCodeExecutor will raise a clear error if Docker is not available + # The error message includes detailed instructions on how to fix the issue + executor = ContainerCodeExecutor(image="python:3-slim", error_retry_attempts=1) + logger.info("ContainerCodeExecutor initialized successfully") + return executor + elif code_executor_type == "cube": + # CubeCodeExecutor reads E2B_API_URL / E2B_API_KEY / CUBE_TEMPLATE_ID + # from the environment when the corresponding cfg fields are unset. + # `create()` opens a fresh remote sandbox; pass `sandbox_id=...` in + # the cfg to attach to an existing one instead. + cfg = CubeCodeExecutorConfig(execute_timeout=30.0, idle_timeout=600) + executor = await CubeCodeExecutor.create(cfg) + logger.info("CubeCodeExecutor initialized: sandbox_id=%s", executor.sandbox_id) + return executor + else: + raise ValueError(f"Invalid code executor type: {code_executor_type}. " + "Valid options are: 'unsafe_local', 'container', 'cube'") + +``` + +### Using UnsafeLocalCodeExecutor + +```python +# ... +def create_agent() -> LlmAgent: + """Create an agent with code execution capabilities. + + The agent can: + - Execute Python code blocks generated by the LLM + - Use tools like get_weather_report + - Perform calculations and data processing through code execution + + Note: UnsafeLocalCodeExecutor executes code in the current process context. + For production use, consider using ContainerCodeExecutor for better security. + """ + # Select unsafe_local + executor = _create_code_executor(code_executor_type="unsafe_local") + agent = LlmAgent( + name="code_assistant", + description="Code execution assistant", + model=_create_model(), # You can change this to your preferred model + instruction=INSTRUCTION, + code_executor=executor, # Enables code execution functionality + ) + return agent + + +root_agent = create_agent() +``` + +**Execution Result Example:** + +![UnsafeLocalCodeExecutor Execution Result](../assets/imgs/local0.png) +![UnsafeLocalCodeExecutor Execution Result 1](../assets/imgs/local1.png) + +### Using ContainerCodeExecutor + +```python + +# ... +def create_agent() -> LlmAgent: + """Create an agent with code execution capabilities. + + The agent can: + - Execute Python code blocks generated by the LLM + - Use tools like get_weather_report + - Perform calculations and data processing through code execution + + Note: UnsafeLocalCodeExecutor executes code in the current process context. + For production use, consider using ContainerCodeExecutor for better security. + """ + # Select container + executor = _create_code_executor(code_executor_type="container") + agent = LlmAgent( + name="code_assistant", + description="Code execution assistant", + model=_create_model(), # You can change this to your preferred model + instruction=INSTRUCTION, + code_executor=executor, # Enables code execution functionality + ) + return agent + +# Ensure Docker is installed and running before use +# Linux: sudo systemctl start docker +# Windows/Mac: Start Docker Desktop +``` + +**Execution Result Example:** + +![ContainerCodeExecutor Execution Result](../assets/imgs/container0.png) +![ContainerCodeExecutor Execution Result 1](../assets/imgs/container1.png) + +### Using CubeCodeExecutor + +```python +# ... +async def create_agent() -> LlmAgent: + """Create an agent backed by a remote Cube/E2B sandbox. + + Required environment (read by CubeCodeExecutorConfig.resolve_*): + - E2B_API_URL: Cube/E2B-compatible gateway URL + - E2B_API_KEY: API key for the gateway + - CUBE_TEMPLATE_ID: Cube template id (e.g. `std-XXXXXXXX`) + + Note: `_create_code_executor` is async because `CubeCodeExecutor.create` + opens the remote sandbox over the network. The executor owns the + sandbox; call `await executor.destroy()` when the agent shuts down to + free the remote resource. `executor.close()` only drops the local + handle and lets the sandbox idle out on its own. + """ + # Select cube + executor = await _create_code_executor(code_executor_type="cube") + agent = LlmAgent( + name="code_assistant", + description="Code execution assistant", + model=_create_model(), # You can change this to your preferred model + instruction=INSTRUCTION, + code_executor=executor, # Enables code execution functionality + ) + return agent + +# Install the optional extra before use: +# pip install 'trpc-agent-py[cube]' +# And export the gateway credentials: +# export E2B_API_URL=... +# export E2B_API_KEY=... +# export CUBE_TEMPLATE_ID=... +``` + +#### Attaching to an existing sandbox + +`CubeCodeExecutor` exposes three async factories so callers can choose the +lifecycle policy explicitly. All three read the bound sandbox id from +`cfg.sandbox_id` so it is the single source of truth: + +```python +# 1. Strict create-or-attach: when cfg.sandbox_id is set, attach and assert +# the sandbox is RUNNING; otherwise create a fresh one. +executor = await CubeCodeExecutor.create(cfg) + +# 2. Attach-only: requires cfg.sandbox_id to be set; never creates fresh. +executor = await CubeCodeExecutor.attach(cfg) + +# 3. Attach-or-recreate: invokes `on_recreate` when the sandbox is gone, +# then transparently provisions a new one. Useful for long-lived agents +# whose external locator state must be cleared on recreate. +executor = await CubeCodeExecutor.create_or_recreate( + cfg, on_recreate=lambda old_id: clear_locator(old_id), +) +``` + +`close()` is a no-op for the remote sandbox (it just drops the local +handle); `destroy()` explicitly kills the remote sandbox. + +## Configuration Parameters + +### UnsafeLocalCodeExecutor Parameters + +```python +from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor, CodeBlockDelimiter + +code_executor = UnsafeLocalCodeExecutor( + # Number of retries on code execution failure, default is 2 + error_retry_attempts=2, + + # Code block delimiters, used to identify code blocks in LLM responses + # Default support: ```tool_code\n and ```python\n + code_block_delimiters=[ + CodeBlockDelimiter(start="```python\n", end="\n```"), + CodeBlockDelimiter(start="```tool_code\n", end="\n```"), + ], + + # Working directory; uses a temporary directory if empty + work_dir="", + + # Code execution timeout in seconds + timeout=10, + + # Whether to clean up temporary files after execution, default is True + clean_temp_files=True, +) +``` + +### ContainerCodeExecutor Parameters + +```python +from trpc_agent_sdk.code_executors import ContainerCodeExecutor, CodeBlockDelimiter + +code_executor = ContainerCodeExecutor( + # Docker image name (required, mutually exclusive with docker_path) + image="python:3-slim", + + # Dockerfile path (required, mutually exclusive with image) + # docker_path="/path/to/Dockerfile", + + # base_url for remote Docker (optional) + # base_url="tcp://remote-docker-host:2375", + + # Number of retries on code execution failure, default is 2 + error_retry_attempts=2, + + # Code block delimiters, default uses ```tool_code\n + code_block_delimiters=[ + CodeBlockDelimiter(start="```tool_code\n", end="\n```"), + ], +) +``` + +### CubeCodeExecutor Parameters + +`CubeCodeExecutor` is configured via two dataclasses split by ISP: +`CubeCodeExecutorConfig` carries only sandbox-lifecycle / command-execution +settings, and `CubeWorkspaceRuntimeConfig` carries only workspace settings +(see the next section). + +```python +from trpc_agent_sdk.code_executors.cube import ( + CubeCodeExecutor, + CubeCodeExecutorConfig, +) + +cfg = CubeCodeExecutorConfig( + # Cube template id for new sandboxes; falls back to env CUBE_TEMPLATE_ID. + template=None, + + # E2B-compatible Cube API URL; falls back to env E2B_API_URL. + api_url=None, + + # E2B API key; falls back to env E2B_API_KEY. + api_key=None, + + # Existing remote sandbox id. When set, factories attach instead of + # creating a fresh sandbox. + sandbox_id=None, + + # Default per-command timeout in seconds (float). Shared by the bare + # executor and the workspace runtime. Default: 60.0. + execute_timeout=60.0, + + # Sandbox idle lifetime in seconds (int >= 1); renewed on every + # command. Default: 3600 (1 hour). The underlying e2b API takes + # integer seconds — sub-second values are rejected at construction. + idle_timeout=3600, +) + +executor = await CubeCodeExecutor.create(cfg) +``` + +`CubeCodeExecutor` accepts the same `code_block_delimiters` as the other +executors; by default it adds a `bash` delimiter on top of the default +`python` and `tool_code` delimiters so plain `\`\`\`bash\n ... \n\`\`\`` +fences are also picked up. + +## CubeWorkspaceRuntime + +For skill execution and other use cases that need a per-execution +workspace (input staging, structured program runs, output collection), +the Cube package additionally ships `CubeWorkspaceRuntime`. It composes +`CubeWorkspaceManager` (workspace directory lifecycle), `CubeWorkspaceFS` +(file/directory upload, download and glob-based collection), and +`CubeProgramRunner` (structured `cmd` + `args` execution) on top of the +same `CubeSandboxClient`. + +```python +from trpc_agent_sdk.code_executors._types import ( + WorkspaceOutputSpec, + WorkspacePutFileInfo, + WorkspaceRunProgramSpec, +) +from trpc_agent_sdk.code_executors.cube import ( + CubeCodeExecutor, + CubeCodeExecutorConfig, + CubeWorkspaceRuntimeConfig, + create_cube_workspace_runtime, +) + +executor = await CubeCodeExecutor.create(CubeCodeExecutorConfig()) + +# `workspace_cfg` is optional. When omitted the runtime uses +# DEFAULT_REMOTE_WORKSPACE = "/workspace/cube_agent" as the root. +runtime = create_cube_workspace_runtime( + executor, + workspace_cfg=CubeWorkspaceRuntimeConfig( + # Remote root under which the manager creates per-execution + # `ws__` subtrees. + remote_workspace="/workspace/cube_agent", + ), +) + +manager = runtime.manager() +fs = runtime.fs() +runner = runtime.runner() + +ws = await manager.create_workspace("demo-1") # /workspace/cube_agent/ws_demo-1_ + +await fs.put_files(ws, [ + WorkspacePutFileInfo(path="work/script.py", + content=b"print('script ran')\n"), +]) + +run_result = await runner.run_program( + ws, + WorkspaceRunProgramSpec(cmd="python3", args=["work/script.py"], timeout=15.0), +) +print(run_result.exit_code, run_result.stdout) + +outputs = await fs.collect_outputs( + ws, WorkspaceOutputSpec(globs=["work/*.py"], inline=True), +) +for ref in outputs.files: + print(ref.name, len(ref.content)) + +await manager.cleanup("demo-1") +``` + +The runtime plugs straight into the Skill subsystem — pass it as +`workspace_runtime` when constructing a skill repository (see +[skill.md](skill.md) for details). + +## Code Block Format + +The Agent automatically identifies and executes code blocks in LLM responses. Supported code block formats: + +### Default Format + +````python +```python +print("Hello, World!") +``` + +```tool_code +result = 15 + 27 * 3 +print(result) +``` +```` + +### Execution Result Format + +After code execution, the results are returned to the LLM in the following format: + +````python +```tool_output +96 +``` +```` + +## Supported Languages + +### UnsafeLocalCodeExecutor +- Python (`python`, `py`, `python3`) +- Bash (`bash`, `sh`) + +### ContainerCodeExecutor +- Python (`python`, `py`, `python3`, empty string defaults to Python) +- Bash (`bash`, `sh`) + +### CubeCodeExecutor +- Python (`python`, `py`, `python3`, empty string defaults to Python) +- Bash (`bash`, `sh`) + +## Workflow + +1. **User Query** → Agent receives the user query +2. **LLM Response Generation** → LLM generates a response containing code blocks +3. **Code Extraction** → The framework automatically extracts code blocks (based on `code_block_delimiters`) +4. **Code Execution** → CodeExecutor executes the code +5. **Result Return** → Execution results are returned to the LLM +6. **Final Response** → LLM generates the final response based on the execution results + + +## 123 Sandbox CodeExecutor Usage + +Reference: Pcg123 Sandbox Usage Example (example to be added) + +## FAQ + +### 1. Docker Connection Failure + +**Problem:** Docker connection failure is reported when using ContainerCodeExecutor + +**Solution:** +- Linux: Ensure the Docker daemon is running: `sudo systemctl start docker` +- Windows/Mac: Start the Docker Desktop application +- Check Docker permissions: `sudo chmod 666 /var/run/docker.sock` or add the user to the docker group +- Verify Docker is running: `docker ps` +- If using remote Docker, check the `base_url` configuration + +### 2. Code Execution Timeout + +**Problem:** Code execution takes too long and times out + +**Solution:** +```python +# Set timeout for UnsafeLocalCodeExecutor +code_executor = UnsafeLocalCodeExecutor(timeout=30) # 30-second timeout +``` + +### 3. Code Execution Fails with No Error Message + +**Problem:** Code execution fails but no error message is displayed + +**Solution:** +- Check the `error_retry_attempts` setting and increase the retry count +- Review the log output; the framework logs detailed error information +- For ContainerCodeExecutor, check the container logs + +### 4. CubeCodeExecutor Cannot Connect / Authenticates as Wrong Tenant + +**Problem:** `CubeCodeExecutor.create` raises with messages like +`Cube sandbox requires \`api_url\` or E2B_API_URL env`, `... api_key ...`, +or `... template ... CUBE_TEMPLATE_ID ...`. + +**Solution:** +- Install the optional extra: `pip install 'trpc-agent-py[cube]'` +- Export the three required env vars (or pass them on + `CubeCodeExecutorConfig`): `E2B_API_URL`, `E2B_API_KEY`, `CUBE_TEMPLATE_ID` +- For multi-tenant deployments, prefer setting the cfg fields explicitly so + each agent instance uses its own credentials instead of falling back to + the process-wide environment + +### 5. CubeCodeExecutor Sandbox Disappears Between Calls + +**Problem:** A sandbox attached via `cfg.sandbox_id` raises +`SandboxNotFoundException` (gone) or `SandboxException` (PAUSED) on the +next command. + +**Solution:** +- For long-lived agents, use `CubeCodeExecutor.create_or_recreate(cfg, on_recreate=...)` + so the executor transparently provisions a new sandbox and notifies the + caller to clear any external locator state +- Tune `idle_timeout` (default 3600s) upward if you legitimately need a + longer idle window between commands; every command renews the lease +- Use `CubeWorkspaceManager.cleanup(exec_id)` instead of `executor.destroy()` + if you only want to drop one workspace while keeping the sandbox alive + +## Complete Example + +See the complete example code: [examples/code_executors/agent/agent.py](../../../examples/code_executors/agent/agent.py) + +End-to-end Cube example (executor + workspace runtime): +[examples/code_executors/cube_demo.py](../../../examples/code_executors/cube_demo.py) + +## Security Recommendations + +1. **Production Environment**: It is strongly recommended to use `ContainerCodeExecutor` for sandbox isolation +2. **Code Review**: Review LLM-generated code before deploying to production +3. **Resource Limits**: Set appropriate resource limits for ContainerCodeExecutor +4. **Access Control**: Restrict the code executor's file system access permissions +5. **Network Isolation**: Restrict container network access as needed + +## Performance Considerations + +- **UnsafeLocalCodeExecutor**: Fast execution speed, suitable for rapid iteration +- **ContainerCodeExecutor**: The initial startup requires pulling the image; subsequent executions are relatively fast +- **CubeCodeExecutor**: Adds network round-trips to a remote sandbox per command, but amortizes well for long-lived sessions because the sandbox is reused across calls (and across processes via `sandbox_id`); workspace file transfers use a tar-based protocol so directory uploads/downloads stay a single round-trip +- It is recommended to use ContainerCodeExecutor or CubeCodeExecutor in production environments and UnsafeLocalCodeExecutor in development environments diff --git a/docs/mkdocs/en/custom_agent.md b/docs/mkdocs/en/custom_agent.md new file mode 100644 index 000000000..684b523f0 --- /dev/null +++ b/docs/mkdocs/en/custom_agent.md @@ -0,0 +1,261 @@ +# Custom Agent + +When the framework's preset multi-agent modes (Chain/Parallel/Cycle) and their combinations cannot meet your requirements, you can directly inherit `BaseAgent` and implement custom control flow to define **arbitrary multi-agent orchestration logic**. + +Custom Agents are also suitable for **complex orchestration scenarios** that the framework's preset multi-agent modes (Chain / Parallel / Cycle) and their combinations cannot handle: conditional routing, dynamic agent selection, complex state management, and more can all be freely implemented within a single `_run_async_impl`. + + +## Use Cases +Custom Agents are suitable for complex scenarios that the framework's built-in Multi Agents cannot handle, as illustrated below: + +- **Conditional Logic**: Execute different sub-agents or take different paths based on runtime conditions or results from the previous step. For example, an intelligent diagnostic system first lets TriageAgent analyze symptoms — if "fever + cough" is detected, it invokes RespiratoryAgent; if "chest pain + shortness of breath" is detected, it invokes CardiacAgent. Agents on different paths use entirely different consultation strategies. +- **Complex State Management**: Implement complex state maintenance and update logic throughout the workflow that goes beyond simple sequential passing. For example, in a multi-round debate system, ArgumentAgent proposes a viewpoint, CriticAgent refutes it and updates the `argument_strength` state, then DefenderAgent decides based on the strength value whether to invoke ReinforcementAgent to strengthen the argument or proceed directly to ConclusionAgent. +- **External System Integration**: Directly integrate calls to external APIs, databases, or custom libraries within the orchestration control flow. For example, an intelligent news writing system lets DataCollectorAgent call a news API to gather material, decides based on the API response status whether to let FactCheckerAgent verify the information, and finally lets WriterAgent choose different writing styles based on the verification results. +- **Dynamic Agent Selection**: Choose which sub-agent to run next based on dynamic evaluation of the situation or input. For example, a code review system lets ComplexityAnalyzerAgent analyze code complexity — if complexity_score > 8, it invokes SeniorReviewerAgent and SecurityAuditorAgent; if < 3, it only invokes BasicReviewerAgent. +- **Custom Business Workflows**: Implement orchestration logic that does not conform to standard sequential, parallel, or loop structures. For example, an AI game strategy system forms a triangular loop among StrategyAgent, TacticsAgent, and ExecutionAgent, where each agent can hand control to either of the other two agents based on changes in the game situation. + +## Implementation Overview + +Inherit `BaseAgent` and implement the `_run_async_impl` method: + +```python +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from typing import AsyncGenerator + +class MyCustomAgent(BaseAgent): + """Custom Agent example""" + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + # Implement your custom logic here + ... +``` +## Core Method Details + +The `_run_async_impl` method is the core of a Custom Agent. You need to implement the following within it: + +1. **Run sub_agents**: Use `sub_agent.run_async(ctx)` to execute sub-agents and propagate events +2. **Manage state**: Read and write the state dictionary via `ctx.session.state` to pass data between agent invocations +3. **Implement control flow**: Use standard Python constructs (`if`/`elif`/`else`, `for`/`while` loops, `try`/`except`) to create complex conditional or iterative workflows + + +## Implementing Custom Logic +### Core Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `_run_async_impl` | `(ctx: InvocationContext) -> AsyncGenerator[Event, None]` | **Must implement**. Core business logic that produces an event stream via `yield` | +| `run_async` | `(parent_context: InvocationContext) -> AsyncGenerator[Event, None]` | Public entry point inherited from `BaseAgent`. Automatically manages callbacks and context, internally calls `_run_async_impl` | + +### Core Patterns + +- Read and write state via `ctx.session.state` to pass data between agent invocations +- Use `sub_agent.run_async(ctx)` to run sub-agents, and yield events one by one with `async for event in ...` +- Use `create_text_event(ctx, text)` to create custom text events +- Determine whether to terminate early via `ctx.actions.escalate` or `ctx.session.state` + +### Running Sub-Agents + +Choose when to run sub-agents as needed within `_run_async_impl`. Sub-agents can be `LlmAgent`, `ChainAgent`, `ParallelAgent`, or even another custom agent. + +```python +async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + async for event in self.some_sub_agent.run_async(ctx): + yield event +``` + +### State Management and Conditional Control Flow + +It is recommended to manage runtime state through the framework's State, allowing agents to flexibly participate in conditional control flows. + +```python +async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + # Read data written by the previous agent (field is set by the agent's output_key) + previous_result = ctx.session.state.get("some_key") + + if previous_result == "value_a": + async for event in self.agent_a.run_async(ctx): + yield event + else: + async for event in self.agent_b.run_async(ctx): + yield event +``` + +### Terminating Agent Execution + +In certain scenarios (e.g., Cycle loops), you may need to terminate the agent application execution early. You can use `ctx.actions.escalate` and similar mechanisms to achieve early termination. + +```python +async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + async for event in self.some_sub_agent.run_async(ctx): + if ctx.actions.escalate: + return + if ctx.session.state.get("process_complete"): + return + yield event +``` + +### Controlling Event Visibility + +In certain scenarios, an agent may produce information during execution that should not be visible externally (e.g., critical reasoning processes). You can control whether an event is returned by the Runner by setting the event's `visible` field. + +The framework also provides the `create_text_event` utility function for conveniently creating text events: + +```python +from trpc_agent_sdk.events import create_text_event + +async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + async for event in self.analyzer.run_async(ctx): + if should_hide(event): + event.visible = False + yield event + + # Create an invisible internal log event + yield create_text_event( + ctx=ctx, + text="Internal processing: analyzing document type...", + visible=False, + ) +``` + +## Example: Intelligent Document Processing Agent + +The following is a practical Custom Agent example that demonstrates dynamically selecting a processing pipeline based on document type: + +```python +from trpc_agent_sdk.agents import BaseAgent, LlmAgent, ChainAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from pydantic import ConfigDict +from typing import AsyncGenerator + +class SmartDocumentProcessor(BaseAgent): + """Intelligent Document Processing Agent + + Dynamically selects a processing strategy based on document type and content complexity: + - Simple documents: process directly + - Complex documents: analyze → process → validate + - Technical documents: specialized processing pipeline + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + def __init__(self, **kwargs): + # Define various processing agents + self.document_analyzer = LlmAgent( + name="document_analyzer", + model="deepseek-chat", + instruction="Analyze the document type and complexity, output: simple/complex/technical", + output_key="doc_type" + ) + + self.simple_processor = LlmAgent( + name="simple_processor", + model="deepseek-chat", + instruction="Process simple document: {user_input}", + output_key="processed_content" + ) + + # Complex document processing chain: analyze → process + complex_analyzer = LlmAgent( + name="complex_analyzer", + model="deepseek-chat", + instruction="Perform in-depth analysis of complex document structure and key points: {user_input}", + output_key="complex_analysis" + ) + + complex_processor = LlmAgent( + name="complex_processor", + model="deepseek-chat", + instruction="Process complex document based on analysis: {complex_analysis}", + output_key="processed_content" + ) + + # Wrap the complex document processing pipeline using ChainAgent + self.complex_processor_chain = ChainAgent( + name="complex_processor_chain", + description="Complex document processing: analyze → process", + sub_agents=[complex_analyzer, complex_processor] + ) + + self.technical_processor = LlmAgent( + name="technical_processor", + model="deepseek-chat", + instruction="Process using the technical document specialized pipeline: {user_input}", + output_key="processed_content" + ) + + self.quality_validator = LlmAgent( + name="quality_validator", + model="deepseek-chat", + instruction="Validate processing quality: {processed_content}, output suggestions if issues are found", + output_key="quality_feedback" + ) + + # Add all agents to sub_agents + sub_agents = [ + self.document_analyzer, + self.simple_processor, + self.complex_processor_chain, # Complex document processing pipeline wrapped with ChainAgent + self.technical_processor, + self.quality_validator + ] + + super().__init__(sub_agents=sub_agents, **kwargs) + + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Implement the custom logic for intelligent document processing""" + + # Step 1: Analyze document type + async for event in self.document_analyzer.run_async(ctx): + yield event + + doc_type = ctx.session.state.get("doc_type", "simple") + + # Step 2: Select processing strategy based on document type + if doc_type == "simple": + # Process simple documents directly + async for event in self.simple_processor.run_async(ctx): + yield event + + elif doc_type == "complex": + # Complex documents: use ChainAgent to execute analyze → process pipeline + async for event in self.complex_processor_chain.run_async(ctx): + yield event + + # Complex documents require quality validation + async for event in self.quality_validator.run_async(ctx): + yield event + + elif doc_type == "technical": + # Technical documents use a specialized pipeline + async for event in self.technical_processor.run_async(ctx): + yield event + + # Technical documents also require validation + async for event in self.quality_validator.run_async(ctx): + yield event + + # Additional conditional logic can be added here, for example: + # - Check processing result quality to decide whether to reprocess + # - Determine whether to execute additional steps based on user permissions + # - Adjust the processing pipeline based on external system status +``` + +## Complete Example + +For the complete Custom Agent example, see: [examples/llmagent_with_custom_agent/run_agent.py](../../../examples/llmagent_with_custom_agent/run_agent.py) + + +## Extension Recommendations + +| Direction | Approach | +|-----------|----------| +| **Integrate Tools** | Configure sub-agents with the `tools` parameter, such as `FunctionTool` to connect databases / HTTP / internal services | +| **Add Validation** | Perform parameter validation, risk control, and feature flag checks before branching | +| **Progressive Evolution** | When if-else branches become excessive or collaboration is needed, smoothly transition to `ChainAgent` / `ParallelAgent` or `Graph` | diff --git a/docs/mkdocs/en/evaluation.md b/docs/mkdocs/en/evaluation.md new file mode 100644 index 000000000..9028b06d7 --- /dev/null +++ b/docs/mkdocs/en/evaluation.md @@ -0,0 +1,2675 @@ +# Agent Evaluation + +This article introduces the core concepts of Agent evaluation, the design philosophy behind eval sets and evaluators, and how to implement regression evaluation in real-world projects. For specific usage and execution methods, see: [Using pytest for Agent Evaluation](#using-pytest-for-agent-evaluation), [Using WebUI for Agent Evaluation](#using-webui-for-agent-evaluation). + +## Why Agent Evaluation + +As large language models and tool ecosystems mature, Agents are gradually moving from experimental scenarios into business-critical pipelines, with increasingly frequent version iterations. At this point, delivery quality is no longer determined by "whether a single demo succeeds," but by whether behavior remains **stable and regressable** as models, prompts, tools, knowledge bases, and orchestration continue to evolve. **Behavior drift** commonly occurs during iterations—wrong tool selection, changes in parameter structure, altered output formats, etc. Without evaluation to solidify expectations, regression costs become very high. + +Unlike deterministic programs, Agent issues are mostly **probabilistic deviations**—the same input may produce different results across multiple runs, making reproduction and replay difficult. Root-cause analysis often requires inspecting logs, traces, and external dependencies, resulting in high issue-resolution costs. + +The purpose of **evaluation** is to solidify key scenarios and acceptance criteria into reusable assets, forming sustainable regression signals. The evaluation module in tRPC-Agent-Python provides out-of-the-box evaluation capabilities: managing test cases and persisting results through eval sets and eval configurations, with built-in evaluators for tool trajectory, response matching, and LLM Judge, along with support for multi-turn conversations, multiple repeated runs, Trace mode, callbacks, and context injection—facilitating both local debugging and CI pipeline integration. + +## Think Before You Evaluate + +Before writing test cases and configurations, it is advisable to think through three things. + +**What counts as a pass?** That is, for the current Agent, what is the criterion for a conversation to "pass"—whether it requires correct tool calls, whether the response contains certain types of information or conforms to a specific format, or whether an LLM determines it as acceptable based on rules. Only after clarifying this can you determine what expectations to write in test cases and which evaluation metrics to use. + +**What are the key tasks?** That is, which user needs or business scenarios should this evaluation cover. It is recommended to first identify the most critical scenarios, write test cases for them and get them running, then expand as needed. + +**Which metrics do you plan to use?** That is, which evaluation methods and passing thresholds to enable in the eval configuration, which should be able to quantify the passing criteria you defined in "What counts as a pass." For specific configuration, see [Using pytest for Agent Evaluation](#using-pytest-for-agent-evaluation). + +## What to Evaluate: Trajectory and Final Response + +Evaluation targets two types of objects: **trajectory** and **final response**, which can be used independently or in combination, depending on your passing criteria. + +**Trajectory** refers to the sequence of steps the Agent executes before responding to the user (e.g., first query the knowledge base, then call an API, then compose the response). During evaluation, the framework compares "which tools were actually called, what parameters were passed, and in what order" against the expected trajectory in the test case on a turn-by-turn basis. If the passing criteria include "tools and parameters must be correct," simply write the expected tool calls in the test case and select trajectory-based evaluation methods in the eval configuration. + +**Final response** refers to the text or structured content the Agent returns to the user. When a standard answer exists, you can require the actual response to exactly match the expected one, contain a specific passage, or be semantically similar. When there is no verbatim standard answer but you can describe what constitutes a good response, an LLM can determine acceptability based on rules or rubrics. For details on supported evaluation methods and configuration, see [Using pytest for Agent Evaluation](#using-pytest-for-agent-evaluation). + +## How the Evaluation Module Works + +**Input**: An eval set (JSON, containing multiple test cases with user input for each turn, optional expected tool calls and expected responses), an eval configuration file in the same directory (specifying metrics and thresholds), and the Agent module passed in as a parameter. + +**Flow**: Load eval set and configuration → Load Agent → For each test case, send user messages to the Agent turn by turn, collect actual tool calls and final responses → Compare actual results against expectations using the configured metrics, compute scores → Pass if all thresholds are met, otherwise assertion fails. Multiple runs can be configured to compute pass@k, and results can be written to a specified directory. + +**Minimal example and directory conventions**: See [Quickstart](../../../examples/evaluation/quickstart/) and [Using pytest for Agent Evaluation](#using-pytest-for-agent-evaluation). + +## How to Run Evaluation + +**pytest**: Execute pytest in the directory where the test cases reside (e.g., Quickstart's `pytest test_quickstart.py -v -s`). For environment, dependencies, and more usage, see [Using pytest for Agent Evaluation](#using-pytest-for-agent-evaluation). + +**WebUI**: Start the Debug Server and adk-web, then select the Agent and eval set in the browser to run. See [Using WebUI for Agent Evaluation](#using-webui-for-agent-evaluation). + +## Using pytest for Agent Evaluation + +### Overview + +#### What Is This + +The tRPC-Agent evaluation module is an **automated Agent quality assurance toolkit**. It allows you to write evaluation test cases like unit tests to verify whether the Agent's behavior meets expectations—including whether the Agent called the correct tools, passed the correct parameters, and whether the final response contains key information. + +#### Why Use pytest + +Triggering evaluation through pytest allows eval test cases to be integrated into automated testing or CI/CD pipelines, without the need to start a web service or interact with a GUI—suitable for local regression and continuous integration. + +#### What Can Evaluation Do + +| Capability | Description | Typical Scenario | +| --- | --- | --- | +| Tool Call Verification | Checks whether the Agent called the correct tools with matching parameters | Verify that a weather Agent actually calls `get_weather` when encountering weather questions | +| Final Response Verification | Checks whether the Agent's response contains expected content | Verify that the response contains a temperature value | +| LLM Judge Evaluation | Uses another LLM as a judge to make semantic-level assessments of responses | Verify whether a response is reasonable or consistent with a reference answer | +| LLM Rubric Evaluation | Uses an LLM judge to score responses item by item against multiple rubrics | Verify that a response simultaneously satisfies multiple quality requirements such as "clear conclusion" and "on-topic" | +| Knowledge Recall Evaluation | Evaluates whether retrieved knowledge in RAG scenarios is sufficient to support the answer | Verify that knowledge base retrieval results cover the key facts in the question | +| Multiple Runs and Statistics | Runs the same test case multiple times, computing stability metrics such as pass@k | Evaluate the Agent's pass rate across multiple attempts | +| Trace Replay | Skips inference, directly scores using pre-recorded conversation traces | Perform offline evaluation using production logs without consuming inference resources | +| External Agent Evaluation | Evaluate Agents not created by this framework via `call_agent` (HTTP services, CLI, other frameworks) | Run regression tests against an existing Claude Code CLI or remote API | +| Callback Hooks | Attach custom logic at 8 lifecycle points during inference/scoring | Instrumentation, logging, sampling, reporting | + +#### Overall Evaluation Flow + +A complete evaluation consists of three steps: **Load → Infer → Score**. + +``` + Files You Prepare Framework Auto-Execution + ┌─────────────────────┐ ┌───────────────────────────────────┐ + │ Eval Set File │ │ │ + │ (.evalset.json) │──Load──▶│ AgentEvaluator │ + │ · User input │ │ │ │ + │ · Expected tool │ │ ├─ Inference phase: invoke │ + │ calls │ │ │ Agent per case, produce │ + │ · Expected final │ │ │ actual tool calls & reply │ + │ response │ │ │ │ + ├─────────────────────┤ │ └─ Scoring phase: compare │ + │ Eval Config File │ │ actual vs expected by │ + │ (test_config.json) │──Load──▶│ metrics, compute scores │ + │ · Which metrics │ │ compare with thresholds, │ + │ · Thresholds │ │ determine pass/fail │ + │ · Matching rules │ │ │ + └─────────────────────┘ │ ──▶ Output: EvaluateResult │ + └───────────────────────────────────┘ +``` + +- **Eval Set File**: Describes "what to test"—what the user will say, what tools the Agent should call, and what it should reply. +- **Eval Config File**: Describes "how to judge"—which metrics to use for evaluation, what the matching strategy is, and what score counts as a pass. +- **AgentEvaluator**: The framework entry point that loads files, drives inference, executes scoring, and aggregates results. + +--- + +### Quick Start + +This section provides a minimal runnable example to help you complete your first evaluation in 5 minutes. For the complete example, see [examples/evaluation/quickstart/](../../../examples/evaluation/quickstart/). + +#### Step 1: Environment Setup + +**System Requirements**: Python 3.12 is required; you also need an accessible LLM model service. + +**Install Dependencies** (includes pytest, pytest-asyncio, rouge-score, etc.): + +```bash +pip install -e ".[eval]" +``` + +**Configure Environment Variables**: + +```bash +export TRPC_AGENT_API_KEY="your-api-key" +## Optional +export TRPC_AGENT_BASE_URL="https://api.example.com/v1" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +#### Step 2: Prepare Files + +You need to prepare 4 files, organized as follows: + +``` +quickstart/ +├── test_quickstart.py ← Test entry point (pytest runs this file) +└── agent/ + ├── agent.py ← Agent definition + ├── weather_agent.evalset.json ← Eval set (what to test) + └── test_config.json ← Eval configuration (how to judge) +``` + +##### File 1: Agent Definition (`agent/agent.py`) + +Build an evaluable Agent. The `instruction` constrains the Agent to answer weather questions using tools. The quickstart actually reads model configuration from `config` and registers multiple weather tools; for the complete code, see [quickstart/agent/agent.py](../../../examples/evaluation/quickstart/agent/agent.py). Below is a minimal illustration. + +```python +## agent/agent.py (illustration; see quickstart/agent/agent.py for full version) +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import FunctionTool + +def get_weather(city: str): + """Query the current weather for a specified city.""" + return {"city": city, "temperature": 20, "condition": "sunny"} + +def create_agent() -> LlmAgent: + return LlmAgent( + name="weather_agent", + description="Weather query assistant", + model=OpenAIModel(model_name="your-model", api_key="your-key", base_url="https://..."), + instruction="You are a weather assistant. Use get_weather to query weather.", + tools=[FunctionTool(get_weather)], + ) + +root_agent = create_agent() +``` + +##### File 2: Eval Set (`agent/weather_agent.evalset.json`) + +The eval set describes "what to test": what the user will say, what tools the Agent is expected to call, and what it is expected to reply. + +- `eval_set_id`: Identifies this eval set. +- `eval_cases`: List of test cases. Each case has a unique `eval_id`. +- `conversation`: Multi-turn conversation sequence. During the inference phase, `user_content` is taken turn by turn in this order as input. +- `intermediate_data.tool_uses`: Expected tool calls (for trajectory evaluator comparison). +- `final_response`: Expected final response (for final response evaluator comparison). +- `session_input`: Session initialization information (`app_name`, `user_id`, `state`). + +The tool `id` is typically generated at runtime and is not used as a matching criterion. + +```json +{ + "eval_set_id": "weather_agent_quickstart", + "name": "Weather Agent Single Case", + "description": "Quickstart single-turn weather query evaluation", + "eval_cases": [ + { + "eval_id": "simple_weather_001", + "conversation": [ + { + "invocation_id": "e-quick-001", + "user_content": { + "parts": [{"text": "上海天气怎么样"}], + "role": "user" + }, + "final_response": { + "parts": [{"text": "18°C"}], + "role": "model" + }, + "intermediate_data": { + "tool_uses": [ + { + "id": "t1", + "name": "get_weather", + "args": {"city": "上海"} + } + ] + } + } + ], + "session_input": { + "app_name": "weather_agent", + "user_id": "user", + "state": {} + } + } + ] +} +``` + +##### File 3: Eval Configuration (`agent/test_config.json`) + +The eval configuration describes "how to judge": which metrics to use, what the matching strategy is, and what score counts as a pass. + +- `metrics`: An array of metrics. Each metric has a `metric_name` (selects the evaluator), `threshold` (passing threshold), and `criterion` (evaluation criteria). +- The example below configures two metrics: tool trajectory (tool name and parameters must match exactly, score above 0.8 to pass) and final response (response contains expected text, score above 0.6 to pass). + +```json +{ + "metrics": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": { + "tool_trajectory": { + "default": { + "name": {"match": "exact", "case_insensitive": false}, + "arguments": {"match": "exact"} + }, + "order_sensitive": false, + "subset_matching": false + } + } + }, + { + "metric_name": "final_response_avg_score", + "threshold": 0.6, + "criterion": { + "final_response": { + "text": {"match": "contains", "case_insensitive": true} + } + } + } + ] +} +``` + +##### File 4: Test Entry Point (`test_quickstart.py`) + +In the pytest test, call `AgentEvaluator.evaluate()`, passing in the Agent module path and the eval set file path. The framework will load `root_agent` from the specified module, load `test_config.json` from the same directory as the eval set file, then execute inference and scoring. + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator + +@pytest.mark.asyncio +async def test_quickstart_with_eval_set(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "weather_agent.evalset.json") + + await AgentEvaluator.evaluate( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + print_detailed_results=True, + ) +``` + +#### Step 3: Execute Evaluation + +```bash +cd examples/evaluation/quickstart +pytest test_quickstart.py -v --tb=short -s +``` + +During evaluation, the framework reads the eval set file and eval configuration file, loads the Agent and performs inference per test case, then completes scoring based on eval metrics. If a directory path is provided, the framework recursively scans the directory for all `.evalset.json` and `.test.json` files and evaluates each one. + +#### Step 4: View Results + +- **All passed**: The terminal prints an evaluation result summary; if `print_detailed_results=True`, it also prints detailed comparison information for each test case. +- **Some cases below threshold**: The framework raises an `AssertionError`, with the failure summary included in the error message as JSON. +- **Result persistence**: If `eval_result_output_dir` is passed during invocation, the results of the current evaluation will be written to a `.evalset_result.json` file in that directory (see the [Evaluation Results](#evaluation-results) section for details). + +--- + +### Core Concepts + +This section explains the components of the evaluation module and their relationships. After understanding these concepts, you will clearly know "which configuration file affects which stage." + +#### Key Components + +| Component | Responsibility | What You Need to Do | +| --- | --- | --- | +| **AgentEvaluator** | The entry point exposed to users, providing `evaluate()` and `get_executer()` | Call it in pytest tests | +| **Eval Set (EvalSet)** | Describes "what to test"—scenarios, user inputs, expected outputs | Write `.evalset.json` files | +| **Eval Config (EvalConfig)** | Describes "how to judge"—which metrics, thresholds, matching rules | Write `test_config.json` files | +| **Eval Service (LocalEvalService / RemoteEvalService)** | The engine that executes inference and scoring (local Agent or `call_agent`) | Automatically created by the framework; usually no action needed | +| **Evaluator** | The concrete implementation that computes scores per metric | Choose built-in evaluators, or register custom ones | +| **Evaluator Registry (EvaluatorRegistry)** | Maintains the mapping from `metric_name` to evaluator type | Register when custom evaluators are needed | +| **Evaluation Result (EvaluateResult)** | Holds the structured evaluation results | Obtain and analyze via `get_result()` | + +#### How Components Collaborate + +AgentEvaluator is the entry point and orchestrator of the entire evaluation flow: + +1. **Loading Phase**: AgentEvaluator loads the EvalSet from eval set files (`.evalset.json` / `.test.json`), loads the EvalConfig from `test_config.json` in the same directory; for local Agent paths, loads the Agent by `agent_module` (can be omitted when using `call_agent` or when all cases use [Trace Mode](#trace-mode)). +2. **Building the Eval Service**: AgentEvaluator writes the EvalSet into InMemoryEvalSetsManager; when `call_agent` is provided, creates RemoteEvalService; otherwise creates LocalEvalService (depending on the Manager, UserSimulatorProvider, optional EvalSetResultsManager, Runner, and Callbacks). +3. **Inference Phase**: The eval service performs turn-by-turn inference based on test cases and conversations in the EvalSet: LocalEvalService drives the Runner to call the Agent; RemoteEvalService calls `call_agent(query)` to obtain each turn's actual response, producing actual Invocation lists. +4. **Scoring Phase**: The eval service obtains evaluators from the EvaluatorRegistry based on the EvalMetric list in the EvalConfig, scores actual vs. expected item by item, and aggregates into EvalCaseResult. +5. **Result Aggregation**: AgentEvaluator determines pass/fail based on results, raises `AssertionError` when any test case falls below the threshold, and optionally persists results as `.evalset_result.json`. + +#### AgentEvaluator Parameter List + +`evaluate()` and `get_executer()` accept the same parameters (`evaluate()` internally calls `get_executer()`): + +| Parameter | Type | Description | +| --- | --- | --- | +| eval_dataset_file_path_or_dir | str | Path to eval set file or directory (recursively scans `.evalset.json` / `.test.json`) | +| agent_module | str \| None | Python module path of the Agent created by this framework; mutually exclusive with `call_agent`. Not needed when using `call_agent` or when all cases are Trace mode | +| call_agent | CallAgent \| None | Async callable for Agents not created by this framework (`async def(str)->str`); mutually exclusive with `agent_module` / `runner` | +| num_runs | int | Number of runs per eval set, default 1 | +| agent_name | str \| None | Display name of the Agent | +| print_detailed_results | bool | Whether to print per-case detail comparisons, default True | +| eval_result_output_dir | str \| None | Directory for result persistence; omit for in-memory aggregation only | +| runner | Runner \| None | Custom Runner instance; mutually exclusive with `call_agent` | +| case_parallelism | int \| None | Max concurrent cases during inference | +| case_eval_parallelism | int \| None | Max concurrent cases during scoring | +| callbacks | Callbacks \| None | Lifecycle callbacks | +| eval_metrics_file_path_or_dir | str \| None | Shared eval config file path (overrides same-directory `test_config.json`) | + +--- + +### Eval Set (EvalSet) Writing Guide + +The eval set is the data foundation of evaluation, describing "what to test." This section teaches you how to write eval set files. + +#### File Format and Naming + +- File format: JSON +- File extension: `.evalset.json` or `.test.json` +- Configuration keys support snake_case (e.g., `eval_set_id`, `eval_cases`, `user_content`) + +#### Structure Overview + +The hierarchical structure of an eval set is: **EvalSet → EvalCase → Invocation**. + +``` +EvalSet (an eval set) +├── eval_set_id: unique identifier for the eval set +├── eval_cases: list of test cases +│ ├── EvalCase (a test case) +│ │ ├── eval_id: unique identifier for the case +│ │ ├── eval_mode: default mode / trace mode +│ │ ├── conversation: multi-turn conversation sequence (expected) +│ │ │ ├── Invocation (one turn of conversation) +│ │ │ │ ├── user_content: user input +│ │ │ │ ├── final_response: expected final response +│ │ │ │ └── intermediate_data: expected intermediate data (tool calls, etc.) +│ │ │ └── ...more turns +│ │ ├── actual_conversation: actual trace (Trace mode only) +│ │ ├── session_input: session initialization information +│ │ └── context_messages: additional context injected before each inference turn +│ └── ...more cases +└── ...metadata (name, description, etc.) +``` + +#### Field Details by Level + +##### EvalSet + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| eval_set_id | string | Yes | Unique identifier for the eval set | +| app_name | string | No | Default application name (session/results), can be overridden by EvalCase's session_input.app_name | +| name | string | No | Human-readable name | +| description | string | No | Description | +| eval_cases | EvalCase[] | Yes | List of eval cases | +| creation_timestamp | number | No | Creation timestamp | + +##### EvalCase + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| eval_id | string | Yes | Unique identifier for the case | +| eval_mode | string | No | Empty indicates default mode (live inference); `"trace"` uses actual_conversation as the actual trace without inference | +| conversation | Invocation[] | Required in default mode | Multi-turn interaction sequence; each turn contains user_content, with optional final_response and intermediate_data as expectations | +| actual_conversation | Invocation[] | Required in Trace mode | The actual output trace in Trace mode | +| session_input | SessionInput | No | Session initialization information (app_name, user_id, state) | +| context_messages | Content[] | No | Additional context injected before each inference turn | + +##### Invocation (One Turn of Conversation) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| invocation_id | string | No | Identifier for this turn | +| user_content | Content | Yes | User input for this turn (e.g., parts, role) | +| final_response | Content | No | Expected final response, for evaluator comparison | +| intermediate_data | object | No | Expected intermediate data; contains tool_uses (list of tool calls, each with id, name, args, etc.), tool_responses | +| creation_timestamp | number | No | Timestamp | + +##### SessionInput (Session Initialization) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| app_name | string | Yes | Application name | +| user_id | string | Yes | User identifier | +| state | object | No | Initial session state | + +#### Execution Mechanism + +An EvalSet is identified by `eval_set_id` and contains multiple EvalCases, each identified by `eval_id`. During the inference phase in default mode, `user_content` is read turn by turn from the conversation as input, `session_input.user_id` is used to create sessions, and `session_input.state` is used to inject initial state when necessary; `context_messages` injects additional context before each inference turn. In Trace mode, no inference is performed—`actual_conversation` is directly used as the actual trace for evaluation. The `intermediate_data.tool_uses` and `final_response` in the conversation describe the expected tool trajectory and final response; whether they need to be filled in depends on the selected evaluation metrics. When `eval_mode` is empty, it indicates default mode; when set to `"trace"`, inference is skipped and `actual_conversation` is used as the actual trace. In this case, `conversation` can still be configured as the expected output for evaluator comparison. + +#### Default Mode vs Trace Mode + +| Comparison | Default Mode | Trace Mode | +| --- | --- | --- | +| Configuration | `eval_mode` is empty or omitted | `eval_mode: "trace"` | +| Whether Agent inference is invoked | Yes, the framework actually calls the Agent | No, inference is skipped | +| Source of actual trace | Produced by Agent inference | The `actual_conversation` you provide | +| Source of expected trace | `conversation` | `conversation` (optional) | +| Applicable scenarios | Routine evaluation, regression testing | Replaying production logs, offline evaluation, debugging evaluation flow | +| Whether inference resources are consumed | Yes | No | + +For Trace mode configuration details, see [Advanced Features - Trace Mode](#trace-mode). + +#### Context Injection (context_messages) + +If you want to inject a fixed context before each inference turn (such as system prompts, domain knowledge, or constraints), you can configure `context_messages` on the EvalCase. Each Content has the same structure as messages in the conversation (e.g., parts, role). This is suitable for injecting uniform instructions, knowledge snippets, or format constraints into test cases without repeating them in every user_content. + +For detailed usage and examples, see [Advanced Features - Context Injection](#context-injection). + +--- + +### Eval Configuration (test_config.json) Writing Guide + +The eval configuration describes "how to judge." This section teaches you how to write eval configuration files and how to choose appropriate evaluation metrics. + +#### File Location + +`test_config.json` must be placed in the **same directory** as the eval set file (`.evalset.json` / `.test.json`); the framework loads it automatically. + +> **Advanced**: If you want **multiple eval sets to share a single configuration** (e.g., centralizing all metric definitions in one JSON), pass `eval_metrics_file_path_or_dir` at call time to bypass the same-directory convention. See [Shared Configuration: `eval_metrics_file_path_or_dir`](#shared-configuration-eval_metrics_file_path_or_dir). + +#### Structure Definition + +**EvalConfig** (parsed from `test_config.json`) + +| Field | Type | Description | +| --- | --- | --- | +| metrics | array | Array of metrics, each containing metric_name, threshold, criterion | +| num_runs | number | Number of runs per test case, default 1 | + +**EvalMetric** (a single metric) + +| Field | Type | Description | +| --- | --- | --- | +| metric_name | string | Metric name, matching the registered evaluator name | +| threshold | number | Score threshold for pass/fail | +| criterion | object | Optional, evaluation criteria; different evaluators use different key names within criterion (e.g., tool_trajectory, final_response, llm_judge) | + +Configuration keys support both snake_case (e.g., `metric_name`) and camelCase (e.g., `metricName`). + +#### Built-in Evaluation Metrics Quick Reference + +`metric_name` is used to retrieve evaluators from the EvaluatorRegistry. The currently built-in and registered metrics are as follows: + +| metric_name | Evaluator | One-line Description | When to Use | +| --- | --- | --- | --- | +| `tool_trajectory_avg_score` | TrajectoryEvaluator | Compares actual tool calls against expected tool calls | Need to verify the Agent called the correct tools with correct parameters | +| `final_response_avg_score` | FinalResponseEvaluator | Compares actual response against expected response (text/JSON) | Need to verify the response contains specific text or JSON content | +| `llm_final_response` | LLMFinalResponseEvaluator | LLM judge determines whether the response is consistent with the reference | Response correctness is hard to measure with text matching; semantic assessment needed | +| `llm_rubric_response` | LLMRubricResponseEvaluator | LLM judge scores item by item against rubrics | Need to evaluate response quality across multiple dimensions (correctness, relevance, compliance, etc.) | +| `llm_rubric_knowledge_recall` | LLMRubricKnowledgeRecallEvaluator | LLM judge evaluates whether retrieved knowledge is sufficient to support the answer | RAG scenarios; need to verify that retrieved knowledge covers key facts | + +> Note: `call_agent` mode does not support `tool_trajectory_avg_score`. When evaluating external/black-box Agents, prefer `final_response_avg_score` or LLM Judge metrics. + +**Rubric** refers to evaluation rubrics: in the configuration, `rubrics` is an array listing multiple independently assessable clauses (e.g., "the answer must contain a conclusion," "must be relevant to the question"). The LLM judge gives a pass/fail for each rubric, then aggregates them into the metric's score. + +#### How to Choose Metrics + +``` +What do you need to evaluate? +│ +├─ Did the Agent call the correct tools? +│ └─ Choose tool_trajectory_avg_score +│ +├─ Does the Agent's response contain specific text/values/JSON? +│ └─ Choose final_response_avg_score +│ +├─ Is the Agent's response semantically correct? (hard to measure with exact matching) +│ ├─ Only need an overall "reasonable/unreasonable" judgment +│ │ └─ Choose llm_final_response +│ └─ Need item-by-item evaluation across multiple dimensions +│ └─ Choose llm_rubric_response +│ +├─ Is the RAG-retrieved knowledge sufficient to support the answer? +│ └─ Choose llm_rubric_knowledge_recall +│ +└─ None of the above? + └─ Register a custom evaluator (see the "Custom Evaluator" section) +``` + +> **Tip**: A single configuration file can use multiple metrics simultaneously; the framework applies each one and produces separate scores and statuses. Evaluators compute scores per Invocation turn and aggregate them; the overall score is compared with `threshold` to determine pass or fail. Each `metric_name` within the same eval set should be unique; the order of the `metrics` array is the order of evaluation execution and result display. + +--- + +### Criterion Details + +Criterion defines "what counts as a match"—the rules used to compare actual output against expected output. Different metrics use different key names within `criterion`, and each evaluator only reads its corresponding configuration section. Key names support both snake_case (e.g., `tool_trajectory`) and camelCase (e.g., `toolTrajectory`). + +#### Criterion Key Names by Metric + +| Metric | Key Name in criterion | Description | +| --- | --- | --- | +| tool_trajectory_avg_score | `tool_trajectory` / `toolTrajectory` | Tool trajectory comparison criteria | +| final_response_avg_score | `final_response` / `finalResponse` | Final response comparison criteria | +| llm_final_response | `llm_judge` / `llmJudge` | LLM judge configuration (judge_model, etc.) | +| llm_rubric_response | `llm_judge` / `llmJudge` | LLM judge configuration (judge_model, rubrics) | +| llm_rubric_knowledge_recall | `llm_judge` / `llmJudge` | LLM judge configuration (judge_model, rubrics, knowledge_tool_names) | + +#### TextCriterion (Text Matching Criteria) + +**Purpose**: Specifies "how two strings are considered a match." Used in scenarios such as whether tool names match, whether text in the final response matches, etc. During evaluation, the framework compares the "actual string" (Agent output) against the "expected string" (written in the eval set) using the configured rules. + +**Where to use**: + +- **Tool name matching** (during tool trajectory evaluation): Configure in `tool_trajectory.default.name` (applies to all tools). To configure individually for a specific tool, use the tool name as a key under `tool_trajectory.tool_strategy`, then configure `name` under that key. +- **Final response text matching**: Configure in `final_response.text`. + +**Field Description** + +| Field | Type | Description | +| --- | --- | --- | +| match | string | Matching strategy, see table below | +| case_insensitive | boolean | When true, converts to lowercase before comparison; default false | +| ignore | boolean | When true, skips comparison and always considers it a match; default false | + +**match strategy description**: During comparison, the "actual string" (Agent output) and "expected string" (from the eval set) are compared using the selected strategy to determine pass/fail. + +| match value | Meaning | +| --- | --- | +| `exact` (default) | Passes only when the actual string is **exactly identical** to the expected string. | +| `contains` | Passes when the actual string **contains** the expected string (expected is a substring). | +| `regex` | Treats the expected string as a **regular expression** and searches within the actual string; passes if there is a match. | + +The above are built-in match rules. To use **your own comparison logic** (e.g., strip leading/trailing whitespace before comparison), you can register an entire criterion type (e.g., `FINAL_RESPONSE`, `TOOL_TRAJECTORY`). See "[Custom Criteria](#custom-criteria)" at the end of this chapter. + +**Configuration Snippet Examples** + +Tool name must be an exact match (written in the tool trajectory's `default.name`, or under `tool_strategy` using the tool name as a key, then under `name`): + +```json +{ + "match": "exact", + "case_insensitive": false +} +``` + +Final response only needs to contain the expected text, case-insensitive (`final_response.text`): + +```json +{ + "match": "contains", + "case_insensitive": true +} +``` + +#### JSONCriterion (JSON Matching Criteria) + +**Purpose**: Compares whether two JSON objects are "considered identical." Used for tool arguments, tool results, or JSON content in the final response. Fields can be ignored and numeric tolerances relaxed to avoid false negatives caused by irrelevant or fluctuating fields. + +**Where to use**: JSONCriterion is written as an **inner object** within other configurations: + +- **Tool trajectory**: Write in `tool_trajectory.default.arguments` or `default.result` (applies to all tools); to configure rules individually for a specific tool, use the tool name as a key under `tool_trajectory.tool_strategy`, then write `arguments` or `result` under that key. +- **Final response**: Write in `final_response.json_config`. + +**Field Description** + +| Field | Type | Description | +| --- | --- | --- | +| match | string | Currently only supports `"exact"` (default): both JSON structures must be identical with keys and values matching item by item; numbers are compared using number_tolerance. | +| ignore_tree | object | Fields to remove before comparison. Key is the field name; value of `true` removes that field; an object value recurses into sub-objects for removal. For example, `{"id": true}` ignores the top-level `id`; `{"metadata": {"timestamp": true}}` ignores `metadata.timestamp`. | +| number_tolerance | number | When comparing numbers, the absolute difference must not exceed this value to be considered equal; default 1e-6. For example, 0.01 allows an error margin of 0.01. | +| ignore | boolean | When true, skips comparison and directly considers it a match; default false. | + +**Configuration Snippet Example** + +Ignore `id` and `metadata.timestamp` before comparison, with a numeric tolerance of 0.01 (suitable when tool arguments contain volatile fields like IDs and timestamps): + +```json +{ + "match": "exact", + "ignore_tree": { + "id": true, + "metadata": {"timestamp": true} + }, + "number_tolerance": 0.01 +} +``` + +#### ToolTrajectoryCriterion (Tool Trajectory Criteria) + +**Purpose**: Defines matching rules for "tool call sequences"—comparing actual tool calls against expected ones turn by turn (tool name, arguments, etc.), and determining pass/fail based on your configured strategy. + +**Corresponding metric**: `tool_trajectory_avg_score`, executed by **TrajectoryEvaluator**. Without criterion configuration, strict matching is used (count, order, tool names, and arguments must all be consistent). Each turn scores 1 for a full match and 0 otherwise; the overall score is the turn-by-turn average compared against `threshold`. + +**How to configure**: In `test_config.json`'s `metrics`, for the entry with `metric_name` set to `tool_trajectory_avg_score`, fill in the key `tool_trajectory` (or `toolTrajectory`) under `criterion`, with the value being the configuration object described below. The eval set must provide expected `intermediate_data.tool_uses` in the corresponding case's `conversation`. + +**Field Description** + +| Field | Type | Description | +| --- | --- | --- | +| default | object | Default strategy applied to all tools; contains `name` (TextCriterion), `arguments` (JSONCriterion), `result` (JSONCriterion) | +| tool_strategy | object | Optional. Override strategy by tool name; key is the tool name, value is `{ name?, arguments?, result? }`; only used when specific tools need different comparison methods than default | +| order_sensitive | boolean | Whether order must match; default false (allows unordered matching) | +| subset_matching | boolean | Whether actual tool calls may exceed expected ones; default false (counts must match) | + +The `name`, `arguments`, and `result` in both `default` and `tool_strategy` use the TextCriterion and JSONCriterion configuration formats respectively. If criterion is not configured for the entire metric, TrajectoryEvaluator uses strict matching (count, order, tool names, and arguments must all be consistent). + +**Configuration Snippet Examples** + +Basic usage—all tool names and arguments are compared using "exact match," order is not required, and count does not need to be strictly equal: + +```json +{ + "metrics": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": { + "tool_trajectory": { + "default": { + "name": {"match": "exact", "case_insensitive": false}, + "arguments": {"match": "exact"} + }, + "order_sensitive": false, + "subset_matching": false + } + } + } + ] +} +``` + +Advanced usage—configure specific tools individually (e.g., `get_weather` arguments ignore `request_id`, `search_api` results use numeric tolerance), using `tool_strategy` with tool names as keys: + +```json +{ + "metrics": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": { + "tool_trajectory": { + "default": { + "name": {"match": "exact"}, + "arguments": {"match": "exact"} + }, + "tool_strategy": { + "get_weather": { + "name": {"match": "exact"}, + "arguments": { + "match": "exact", + "ignore_tree": {"request_id": true} + } + }, + "search_api": { + "name": {"match": "exact"}, + "arguments": {"match": "exact"}, + "result": { + "match": "exact", + "number_tolerance": 0.01 + } + } + }, + "order_sensitive": false, + "subset_matching": false + } + } + } + ] +} +``` + +#### FinalResponseCriterion (Final Response Criteria) + +**Purpose**: Defines matching rules for the "final response"—comparing the actual output of each turn against the expected `final_response` (text or JSON), and determining pass/fail based on your configured strategy. + +**Corresponding metric**: `final_response_avg_score`, executed by **FinalResponseEvaluator**. Without criterion configuration, exact text matching is used. Each turn scores 1 for a match and 0 otherwise; the overall score is the turn-by-turn average compared against `threshold`. + +**How to configure**: In `test_config.json`'s `metrics`, for the entry with `metric_name` set to `final_response_avg_score`, fill in the key `final_response` (or `finalResponse`) under `criterion`, with the value being the configuration object described below. The eval set must provide expected `final_response` for each turn in the corresponding `conversation`. + +**Field Description** + +| Field | Type | Description | +| --- | --- | --- | +| text | object | Text comparison strategy (TextCriterion configuration); supports `match`, `case_insensitive`, `ignore` | +| json_config | object | JSON comparison strategy (JSONCriterion configuration); supports `ignore_tree`, `number_tolerance`, `ignore` | + +If both `text` and `json_config` are configured, both must pass (AND). If neither is configured, FinalResponseEvaluator uses default text matching. + +**Configuration Snippet Example** + +Compare using text "contains" with case-insensitivity (common when the final response only needs to contain key information): + +```json +{ + "metrics": [ + { + "metric_name": "final_response_avg_score", + "threshold": 0.6, + "criterion": { + "final_response": { + "text": { + "match": "contains", + "case_insensitive": true + } + } + } + } + ] +} +``` + +#### LLMJudgeCriterion (LLM Judge Criteria) + +**Purpose**: Configures "LLM as judge" model and rules. The specified judge model scores responses or knowledge recall based on the conversation and optional rubrics, then compares against the threshold. + +**Corresponding metrics** (all three use this criterion, with the configuration key being `criterion.llm_judge` / `llmJudge`): + +- **llm_final_response**: Performs semantic assessment of the final answer (whether it is reasonable, whether it is consistent with the reference answer), executed by **LLMFinalResponseEvaluator**; only requires `judge_model` configuration, no rubrics needed. The eval set typically needs to provide `final_response` as a reference; the judge output is mapped to 0/1, and `num_samples` can be set for multiple sampling followed by aggregation before comparing with `threshold`. +- **llm_rubric_response**: Determines whether the final answer satisfies each rubric in the evaluation rubrics, executed by **LLMRubricResponseEvaluator**; requires `judge_model` and `rubrics` configuration, aggregated by rubric pass status before comparing with `threshold`. +- **llm_rubric_knowledge_recall**: Evaluates whether tool retrieval results can support the rubrics, focusing on knowledge base recall, executed by **LLMRubricKnowledgeRecallEvaluator**; requires `judge_model` and `rubrics`, with optional `knowledge_tool_names` (default `["knowledge_search"]`) specifying which tools are considered knowledge retrieval, extracting content from tool outputs as judge input. + +**Field Description** + +| Field | Type | Description | +| --- | --- | --- | +| judge_model | object | Judge model configuration (JudgeModelOptions); required when `judge_models` is not set | +| judge_models | array | Multi-model judge list (JudgeModelOptions items); mutually exclusive with `judge_model`. Cross-model results are combined by `models_aggregator` | +| models_aggregator | string | Cross-model aggregation strategy. Built-in: `all_pass` (default) / `any_pass` / `majority_pass` / `avg` / `weighted_avg` / `weighted_majority`. Custom names must be registered via `LLM_EVALUATOR_REGISTRY.register_models_aggregator` before evaluation | +| parallel | boolean | Whether to run the multiple judge models concurrently; default `true` | +| rubrics | array | Rubric list; required for llm_rubric_response and llm_rubric_knowledge_recall | +| knowledge_tool_names | array | List of knowledge retrieval tool names; used by llm_rubric_knowledge_recall, default `["knowledge_search"]` | + +**JudgeModelOptions** (judge_model field) + +| Field | Type | Description | +| --- | --- | --- | +| model_name | string | Model name (e.g., "glm-4.7") | +| api_key | string | API key | +| base_url | string | Optional, custom endpoint | +| num_samples | number | Number of judge samples per turn; default 1 | +| weight | number | Per-model weight used by `weighted_avg` / `weighted_majority` aggregators; default 1.0 | +| think | boolean | Controls the judge model's thinking mode. `false`: disable thinking (sets both `thinking_config.thinking_budget=0` and `chat_template_kwargs.enable_thinking=false`). `true`: enable thinking with automatic budget (`include_thoughts=true`). Unset (default): keep the model default. Recommended `false` for judge models to save tokens and latency | +| generation_config | object | Generation parameters (max_tokens, temperature, etc.; may also explicitly set `thinking_config` / `http_options`; the `think` field overrides them) | + +**Rubric** (items in the rubrics array) + +| Field | Type | Description | +| --- | --- | --- | +| id | string | Unique identifier for the rubric item | +| content | object | Content presented to the judge model (e.g., `{"text": "..."}`) | +| description | string | Brief description | +| type | string | Rubric type label (e.g., "FINAL_RESPONSE_QUALITY", "KNOWLEDGE_RELEVANCE") | + +**Configuration Snippet Examples** + +LLM final response judgment (only requires judge_model): + +```json +{ + "metrics": [ + { + "metric_name": "llm_final_response", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_model": { + "model_name": "glm-4.7", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}", + "num_samples": 2, + "generation_config": { + "max_tokens": 2000, + "temperature": 0.2 + } + } + } + } + } + ] +} +``` + +LLM response quality with rubrics (llm_rubric_response or llm_rubric_knowledge_recall; `knowledge_tool_names` is only used by llm_rubric_knowledge_recall): + +```json +{ + "metrics": [ + { + "metric_name": "llm_rubric_response", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_model": { + "model_name": "glm-4.7", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}" + }, + "rubrics": [ + { + "id": "1", + "content": { + "text": "The answer must contain a clear conclusion or numerical value" + }, + "description": "Clear conclusion", + "type": "FINAL_RESPONSE_QUALITY" + }, + { + "id": "2", + "content": { + "text": "The answer must be directly relevant to the user's question" + }, + "description": "On-topic", + "type": "RELEVANCE" + } + ] + } + } + } + ] +} +``` + +It is recommended to use environment variable placeholders for `api_key` and `base_url` (e.g., `${TRPC_AGENT_API_KEY}`), which are replaced by the execution environment, to avoid writing plaintext in configuration files. + +> A single LLM judge metric can also use multiple judge models with aggregated results. See [Advanced Features - Multi-Model Judge (Cross-Model Aggregation)](#multi-model-judge-cross-model-aggregation). + +#### Custom Criteria + +To fully customize the "whether it matches" logic in code, you can register a matching function with `CRITERION_REGISTRY` before running the evaluation. Supported types for registration are `TOOL_TRAJECTORY` and `FINAL_RESPONSE`; once registered, comparisons of that type will invoke your provided function `(actual, expected) -> bool`, bypassing the built-in criteria from the configuration file. + +**Usage**: Execute `CRITERION_REGISTRY.register(CriterionType.XXX, your_match_fn)` once **before** calling `AgentEvaluator.evaluate()` or the executer's `evaluate()`. The function signature is `(actual, expected) -> bool`; the meaning and types of the two parameters depend on the criterion type (see examples below). + +**Framework behavior**: The final response evaluator calls `criterion.matches(actual.final_response, expected.final_response)` during turn-by-turn comparison, so the registered **FINAL_RESPONSE** callback receives the current turn's "final response content," typed as `Optional[Content]` (`Content` from `trpc_agent_sdk.types`, containing `parts`, `role`, etc.); the **TOOL_TRAJECTORY** callback receives the current turn's tool call lists, typed as `(list[FunctionCall], list[FunctionCall])`. + +**Example: Registering a Custom FINAL_RESPONSE Comparison** + +```python +from typing import Optional + +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.evaluation import CRITERION_REGISTRY, CriterionType + + +def _content_to_text(value: Optional[Content]) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + parts = getattr(value, "parts", None) + if parts is not None: + return "\n".join(getattr(p, "text", "") or "" for p in parts) + return str(value) + + +def my_final_response_match( + actual: Optional[Content], + expected: Optional[Content], +) -> bool: + """Custom: convert to text, strip, then compare for equality.""" + a = _content_to_text(actual).strip() + e = _content_to_text(expected).strip() + return a == e + + +## Register once before running evaluation +CRITERION_REGISTRY.register(CriterionType.FINAL_RESPONSE, my_final_response_match) +## After this, final_response_avg_score will use my_final_response_match +``` + +The registration function signature for `TOOL_TRAJECTORY` is `(actual_tool_calls: list[FunctionCall], expected_tool_calls: list[FunctionCall]) -> bool`. Registration is typically used for testing or extension when existing configuration is incompatible. + +--- + +### Evaluator Details + +Evaluators are the concrete executors of "scoring." They are retrieved from the evaluator registry based on `metric_name` in the configuration, responsible for comparing "actual trajectory/response" against "expected" for each turn (or each case), computing scores, and determining pass or fail against the threshold. During evaluation, the eval service retrieves the corresponding evaluator for each metric configured in `test_config.json`'s `metrics` and invokes its evaluation logic. All evaluators take the "actual invocation list" and "expected invocation list" of the current evaluation as input, and output evaluation results containing per-turn scores and overall pass status; the overall score is compared against the corresponding metric's `threshold` to determine whether the case passes. + +#### Tool Trajectory Evaluator (TrajectoryEvaluator) + +| Property | Value | +| --- | --- | +| Metric name | `tool_trajectory_avg_score` | +| Eval set requirement | The case's `conversation` must provide `intermediate_data.tool_uses` | +| Configuration criteria | [ToolTrajectoryCriterion](#tooltrajectorycriterion-tool-trajectory-criteria) | +| Scoring logic | Each turn scores 1 for a full match, 0 otherwise; overall is the turn-by-turn average | + +Compares actual vs. expected tool calls turn by turn using ToolTrajectoryCriterion (if configured) or default rules: tool name, arguments (and optional result). Without criterion configuration, strict matching is used: tool call count, order, tool names, and arguments must all be consistent. + +#### Final Response Evaluator (FinalResponseEvaluator) + +| Property | Value | +| --- | --- | +| Metric name | `final_response_avg_score` | +| Eval set requirement | The case's `conversation` must provide `final_response` | +| Configuration criteria | [FinalResponseCriterion](#finalresponsecriterion-final-response-criteria) | +| Scoring logic | Each turn scores 1 for a match, 0 otherwise; overall is the turn-by-turn average | + +Compares actual vs. expected final responses turn by turn using FinalResponseCriterion (if configured) or default rules. Without criterion configuration, exact text matching is used. To compare using "contains" or regex strategies, or to ignore certain JSON fields before comparison, configure `final_response.text` or `final_response.json_config` in the criterion. + +#### LLM Evaluators + +LLM Judge evaluators use a judge model to perform semantic scoring on outputs, suitable for evaluating correctness, completeness, compliance, and other aspects that are difficult to cover with deterministic rules. These evaluators select the judge model through `judge_model` in [LLMJudgeCriterion](#llmjudgecriterion-llm-judge-criteria), and support using `numSamples` to sample the same turn multiple times to reduce judge variance. + +The framework includes the following three built-in LLM Judge evaluators (metrics), which can be selected as needed in `test_config.json`'s `metrics`: + +##### LLM Final Response Evaluator + +| Property | Value | +| --- | --- | +| Metric name | `llm_final_response` | +| Evaluator class | LLMFinalResponseEvaluator | +| Eval set requirement | Typically needs to provide `final_response` as a reference | +| criterion requirement | Requires `llm_judge.judge_model` configuration, no rubrics needed | +| Focus | Consistency between the final answer and the reference answer | + +Uses `judge_model` from LLMJudgeCriterion to invoke the judge model, performing semantic assessment of the final answer (e.g., whether it is reasonable, whether it is consistent with the reference answer). The evaluator organizes user input, expected final answer, and actual final answer as judge input. The judge output is parsed and mapped to 0 or 1, and can be aggregated after `numSamples` multiple samplings before comparing with `threshold`. + +**Configuration example**: + +```json +{ + "metric_name": "llm_final_response", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_model": { + "model_name": "glm-4-flash", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}", + "num_samples": 2, + "generation_config": {"max_tokens": 2000, "temperature": 0.2} + } + } + } +} +``` + +For the complete example, see: [examples/evaluation/llm_final_response/](../../../examples/evaluation/llm_final_response/). + +##### LLM Rubric Response Evaluator + +| Property | Value | +| --- | --- | +| Metric name | `llm_rubric_response` | +| Evaluator class | LLMRubricResponseEvaluator | +| criterion requirement | Requires `llm_judge.judge_model` and `rubrics` configuration | +| Focus | Whether the final answer satisfies each rubric (correctness, relevance, compliance, etc.) | +| Scoring logic | The judge gives a pass/fail for each rubric; single-turn score is the average of all rubric scores | + +**Configuration example**: + +```json +{ + "metric_name": "llm_rubric_response", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_model": { + "model_name": "glm-4-flash", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}" + }, + "rubrics": [ + { + "id": "conclusion", + "content": { + "text": "The answer must contain a clear conclusion or numerical value" + }, + "description": "Clear conclusion", + "type": "FINAL_RESPONSE_QUALITY" + }, + { + "id": "relevance", + "content": { + "text": "The answer must be directly relevant to the user's question" + }, + "description": "On-topic", + "type": "RELEVANCE" + } + ] + } + } +} +``` + +It is recommended to make the rubric's `content.text` specific so that the judge can directly assess based on user input and the final answer. + +For the complete example, see: [examples/evaluation/llm_rubric_response/](../../../examples/evaluation/llm_rubric_response/). + +##### LLM Rubric Knowledge Recall Evaluator + +| Property | Value | +| --- | --- | +| Metric name | `llm_rubric_knowledge_recall` | +| Evaluator class | LLMRubricKnowledgeRecallEvaluator | +| criterion requirement | Requires `llm_judge.judge_model` and `rubrics`, optional `knowledge_tool_names` | +| Focus | Whether retrieved knowledge is sufficient to support key facts in the user's question or rubrics | +| Applicable scenario | Recall quality evaluation in RAG scenarios | + +The evaluator extracts the call results of knowledge retrieval tools (default `knowledge_tool_names` is `["knowledge_search"]`, configurable) from the actual trace as evidence, combines it with user input and rubrics to construct judge input. The judge gives a pass/fail for each rubric; single-turn score is the average of rubric scores, then compared with `threshold`. The actual trace must include knowledge retrieval tool calls with usable results; otherwise, stable judge input cannot be formed. + +**Configuration example**: + +```json +{ + "metric_name": "llm_rubric_knowledge_recall", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_model": { + "model_name": "glm-4-flash", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}" + }, + "rubrics": [ + { + "id": "coverage", + "content": { + "text": "The retrieved content must cover the key facts in the question" + }, + "description": "Recall coverage", + "type": "KNOWLEDGE_COVERAGE" + }, + { + "id": "relevance", + "content": { + "text": "The retrieval results must be relevant to the user's question" + }, + "description": "Recall relevance", + "type": "KNOWLEDGE_RELEVANCE" + } + ], + "knowledge_tool_names": ["knowledge_search"] + } + } +} +``` + +When `knowledge_tool_names` is not configured, the default `["knowledge_search"]` is used; if the actual tool names are `retrieve`, `search`, etc., you can write `"knowledge_tool_names": ["retrieve", "search"]`. + +For the complete example, see: [examples/evaluation/llm_rubric_knowledge_recall/](../../../examples/evaluation/llm_rubric_knowledge_recall/). + +##### Registering Tools for the Judge Agent + +The judge is served by an **LlmAgent** within the framework. If you want the judge model to also be able to call tools during scoring (e.g., querying rules or assessment criteria), you can register a tool list for a specific metric before running the evaluation via **LLM_EVALUATOR_REGISTRY.register_judge_tools(metric_name, tools)**. `metric_name` can be one of `llm_final_response`, `llm_rubric_response`, or `llm_rubric_knowledge_recall`. `tools` follows the same convention as a regular LlmAgent: it can be `BaseTool`, `BaseToolSet`, or a callable (which will be wrapped as FunctionTool). To unregister, use `unregister_judge_tools(metric_name)`. + +When using **llm_rubric_response**, you can specify the tool's **invocation timing and usage** in the rubrics (e.g., "the judge must first call get_eval_policy to obtain assessment criteria before scoring, and only assess based on the clauses returned by that tool"), making the judge depend on tools to complete scoring, which makes the tools more effective. + +```python +from trpc_agent_sdk.evaluation import LLM_EVALUATOR_REGISTRY +from trpc_agent_sdk.tools import FunctionTool + +def get_eval_policy() -> str: + """The judge must call this before scoring: returns the assessment criteria for this case.""" + return ( + "Assessment criteria for this case (3 items):\n" + "1. The final answer must contain a clear temperature value.\n" + "2. The final answer must contain a weather condition description.\n" + "3. The answer must be directly relevant to the user's question." + ) + +LLM_EVALUATOR_REGISTRY.register_judge_tools( + "llm_rubric_response", + [FunctionTool(get_eval_policy)], +) +``` + +For the complete example (including test_config with rubrics specifying tool invocation timing and usage), see [examples/evaluation/llm_judge_tools/](../../../examples/evaluation/llm_judge_tools/). + +##### LLM Evaluator Internal Flow (Five-Step Pipeline) + +The following describes the **internal flow** of LLM evaluators. Except for Step 2 (multiple sampling), the other four steps each correspond to registerable operators, injected via **LLM_EVALUATOR_REGISTRY** with custom implementations; built-in operators are used when none are registered. + +| Step | What It Does | Input → Output | +| --- | --- | --- | +| 1. Message Construction | Organizes the information for "the current turn being judged" into text to send to the judge model | Actual/expected traces, criterion → A user message (string) | +| 2. Multiple Sampling | Using the message from the previous step, calls the judge model `numSamples` times as configured | User message → Multiple raw judge outputs (text) | +| 3. Response Scoring | Parses each raw judge output into a structured **score and reason** | Each raw text → A **ScoreResult** (score, reason, etc.) | +| 4. Sample Aggregation | Aggregates the multiple ScoreResults from the same turn into one representative result | Multiple ScoreResults, threshold → One ScoreResult (representing the turn) | +| 5. Multi-turn Aggregation | Aggregates representative results across turns into an overall score and pass/fail status | Per-turn results, threshold → Overall score + **EvalStatus** (PASSED/FAILED) | + +###### Step 1: Message Construction + +**Purpose**: Constructs the **user message** sent to the judge model for "the current turn." The message typically contains: what the user asked, what the Agent actually answered, what the reference answer is (if any), evaluation rubrics, etc., so the judge can score accordingly. + +**Built-in behavior**: Different metrics use different templates. `llm_final_response` fills in "user input + actual final answer + reference final answer"; `llm_rubric_response` fills in "user input + actual final answer + rubrics"; `llm_rubric_knowledge_recall` extracts knowledge retrieval tool return content from the actual trace as evidence, combined with user input and rubrics. + +**Customization**: If you want the judge to see content in a different format than the built-in one, call `LLM_EVALUATOR_REGISTRY.register_messages_constructor(metric_name, fn)` before running evaluation to register your own construction function. The framework requires `fn` to have the signature `(actuals: list[Invocation], expecteds: Optional[list[Invocation]], criterion: LLMJudgeCriterion, metric_name: str) -> str` (matching `MessagesConstructorFn`), returning a complete user message string. `metric_name` can only be `llm_final_response`, `llm_rubric_response`, or `llm_rubric_knowledge_recall`. + +```python +from typing import Optional + +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.evaluation import ( + LLM_EVALUATOR_REGISTRY, + Invocation, + LLMJudgeCriterion, +) + + +def _text_from_content(c: Optional[Content]) -> str: + """Extract plain text from Content (concatenating part.text from parts).""" + if c is None or not getattr(c, "parts", None): + return "" + return "\n".join((p.text or "") for p in c.parts).strip() + + +def my_messages( + actuals: list[Invocation], + expecteds: Optional[list[Invocation]], + criterion: LLMJudgeCriterion, + metric_name: str, +) -> str: + """Custom: only take the last turn's actual/expected and concatenate as simple text.""" + a = actuals[-1] if actuals else None + e = expecteds[-1] if expecteds else None + a_text = _text_from_content(getattr(a, "final_response", None)) if a else "" + e_text = _text_from_content(getattr(e, "final_response", None)) if e else "" + return f"Actual:\n{a_text}\n\nExpected:\n{e_text}" + + +LLM_EVALUATOR_REGISTRY.register_messages_constructor("llm_final_response", my_messages) +``` + +###### Step 2: Multiple Sampling + +**Purpose**: For **the same turn**, calls the judge model **numSamples** times (configured in the criterion's `numSamples`) using the user message constructed in the previous step. Since a single judge call may be noisy, multiple samplings followed by "sample aggregation" in the next step can produce a more stable per-turn result. + +###### Step 3: Response Scoring + +**Purpose**: Parses the **raw text** returned by the judge model (typically a JSON snippet) into a structured **score and reason**, i.e., a **ScoreResult** (containing `score`, `reason`; rubric-based metrics also parse per-rubric pass status `rubric_scores`). + +**Built-in behavior**: Parses fixed-format JSON based on the metric type. `llm_final_response` checks the field `is_the_agent_response_valid`—valid scores 1, invalid scores 0; `llm_rubric_response` and `llm_rubric_knowledge_recall` parse each rubric's verdict (yes→1, no→0), with the turn score being the average of all rubric scores. + +**Customization**: If your judge output format differs from the built-in format above, call `LLM_EVALUATOR_REGISTRY.register_response_scorer(metric_name, fn)` to register your own parsing function. The framework requires `fn` to have the signature `(response_text: str, metric_name: str) -> ScoreResult` (matching `ResponseScorerFn`); import `ScoreResult` from `trpc_agent_sdk.evaluation` (rubric-based metrics also need `RubricScore`). + +```python +import json + +from trpc_agent_sdk.evaluation import LLM_EVALUATOR_REGISTRY, ScoreResult + + +def my_scorer(response_text: str, metric_name: str) -> ScoreResult: + try: + d = json.loads(response_text.strip()) + return ScoreResult(score=float(d.get("score", 0)), reason=d.get("reason", "")) + except (json.JSONDecodeError, TypeError, KeyError): + return ScoreResult(score=0.0, reason="parse error") + +LLM_EVALUATOR_REGISTRY.register_response_scorer("llm_final_response", my_scorer) +``` + +###### Step 4: Sample Aggregation + +**Purpose**: When `numSamples` > 1, the same turn produces multiple **ScoreResults**. Sample aggregation **merges these results into a single representative result** (one ScoreResult) for the turn, to be used by the subsequent "multi-turn aggregation." + +**Built-in behavior**: **Majority vote**. First, each sample is classified as "passed" or "failed" using the `threshold`; whichever side has more votes is selected, and an arbitrary sample from that side is taken as the representative. In case of a tie, the "failed" side is chosen (more strict). + +**Customization**: Call `LLM_EVALUATOR_REGISTRY.register_samples_aggregator(metric_name, fn)`. The framework requires `fn` to have the signature `(samples: list[ScoreResult], threshold: float) -> ScoreResult` (matching `SamplesAggregatorFn`). For example, you could implement "take the minimum score": if any sample fails, the turn is considered failed. + +```python +from trpc_agent_sdk.evaluation import LLM_EVALUATOR_REGISTRY, ScoreResult + + +def min_score_aggregator(samples: list[ScoreResult], threshold: float) -> ScoreResult: + if not samples: + return ScoreResult(score=0.0, reason="no samples") + return min(samples, key=lambda s: s.score or 0) + +LLM_EVALUATOR_REGISTRY.register_samples_aggregator("llm_final_response", min_score_aggregator) +``` + +###### Step 5: Multi-turn Aggregation + +**Purpose**: An evaluation may have multiple conversation turns (multiple invocations), each with a representative result (**PerInvocationResult**) after Step 4. Multi-turn aggregation **combines these per-turn results into an overall score** and produces the final **pass/fail** status (**EvalStatus**: PASSED / FAILED), compared against the metric's configured `threshold`. + +**Built-in behavior**: **Arithmetic mean**. Only considers turns whose status is not `NOT_EVALUATED`, averages their scores as the overall score; if the overall score ≥ threshold, the overall status is PASSED, otherwise FAILED. If there are no scorable turns, the overall status is NOT_EVALUATED. + +**Customization**: Call `LLM_EVALUATOR_REGISTRY.register_invocations_aggregator(metric_name, fn)`. The framework requires `fn` to have the signature `(results: list[PerInvocationResult], threshold: float) -> tuple[Optional[float], EvalStatus]` (matching `InvocationsAggregatorFn`), returning (overall score, overall status). Import `PerInvocationResult` and `EvalStatus` from `trpc_agent_sdk.evaluation`. + +```python +from typing import Optional + +from trpc_agent_sdk.evaluation import LLM_EVALUATOR_REGISTRY, EvalStatus, PerInvocationResult + + +def my_invocations_aggregator( + results: list[PerInvocationResult], + threshold: float, +) -> tuple[Optional[float], EvalStatus]: + scores = [r.score for r in results if r.eval_status != EvalStatus.NOT_EVALUATED and r.score is not None] + if not scores: + return (None, EvalStatus.NOT_EVALUATED) + overall = sum(scores) / len(scores) + status = EvalStatus.PASSED if overall >= threshold else EvalStatus.FAILED + return (overall, status) + +LLM_EVALUATOR_REGISTRY.register_invocations_aggregator("llm_final_response", my_invocations_aggregator) +``` + +All registrations above must be completed before calling `AgentEvaluator.evaluate()` or the executer's `evaluate()`; registrations take effect by `metric_name` and only affect the LLM evaluator corresponding to that metric. + +#### Custom Evaluator + +The framework maintains the mapping between `metric_name` and evaluator types through **EvaluatorRegistry**. The default registered mappings are as follows: + +| metric_name | Evaluator | +| --- | --- | +| tool_trajectory_avg_score | TrajectoryEvaluator | +| final_response_avg_score | FinalResponseEvaluator | +| llm_final_response | LLMFinalResponseEvaluator | +| llm_rubric_response | LLMRubricResponseEvaluator | +| llm_rubric_knowledge_recall | LLMRubricKnowledgeRecallEvaluator | + +To extend, call `EvaluatorRegistry.register(metric_name, evaluator_class)` in code to register a custom evaluator. Registration must be completed **before** calling `AgentEvaluator.evaluate()` or `get_executer()`; the evaluator class must inherit from **Evaluator**, implement `evaluate_invocations(actual_invocations, expected_invocations) -> EvaluationResult`, and its constructor must accept `eval_metric: EvalMetric`. + +**Example**: Register a custom metric `my_custom_score` whose evaluator gives a fixed score of 1.0 for all turns and determines a pass. + +```python +from trpc_agent_sdk.evaluation import ( + EVALUATOR_REGISTRY, + Evaluator, + EvalMetric, + EvalStatus, + EvaluationResult, + Invocation, + PerInvocationResult, +) + + +class MyCustomEvaluator(Evaluator): + def __init__(self, eval_metric: EvalMetric): + self._eval_metric = eval_metric + + def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: list[Invocation] | None, + ) -> EvaluationResult: + threshold = self._eval_metric.threshold + results = [ + PerInvocationResult( + actual_invocation=inv, + expected_invocation=expected_invocations[i] if expected_invocations and i < len(expected_invocations) else None, + score=1.0, + eval_status=EvalStatus.PASSED, + reason=None, + rubric_scores=None, + ) + for i, inv in enumerate(actual_invocations) + ] + overall_status = EvalStatus.PASSED if 1.0 >= threshold else EvalStatus.FAILED + return EvaluationResult( + overall_score=1.0, + overall_eval_status=overall_status, + per_invocation_results=results, + ) + + +## Register before running evaluation +EVALUATOR_REGISTRY.register("my_custom_score", MyCustomEvaluator) +``` + +**Configuration file example**: When using only the custom metric, `agent/test_config.json` can be: + +```json +{ + "metrics": [ + { + "metric_name": "my_custom_score", + "threshold": 1 + } + ] +} +``` + +When used alongside built-in metrics, simply append an entry to the `metrics` array, for example: + +```json +{ + "metrics": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": {"tool_trajectory": { "..." : "..." }} + }, + { + "metric_name": "my_custom_score", + "threshold": 1 + } + ] +} +``` + +--- + +### Evaluation Results + +After evaluation completes, you can obtain structured results and optionally persist them. This section explains how to obtain results, the result data structure, and how to persist them. Related types are all exported from `trpc_agent_sdk.evaluation`. + +#### Differences Between Two Invocation Methods + +| Method | Returns Result Object | Usage | +| --- | --- | --- | +| `AgentEvaluator.evaluate(...)` | No, only asserts pass/fail | Pass/fail determination in CI/CD | +| `AgentEvaluator.get_executer(...)` | Yes, obtained via `get_result()` | When structured results are needed in code | + +#### Using get_executer to Obtain Results + +First obtain the executer, then `await executer.evaluate()`, and finally `executer.get_result()` to get an **EvaluateResult** (`None` if not completed or an exception occurred). + +> **Note**: When some cases fail, `evaluate()` raises `AssertionError`, so `get_result()` should be placed in `finally` to ensure results are obtained. + +The path follows the same convention as quickstart; for multi-run scenarios, it can be controlled by `num_runs` in the `test_config.json` in the same directory, or passed in here. + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator + +@pytest.mark.asyncio +async def test_eval_and_use_result(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "weather_agent.evalset.json") + + executer = AgentEvaluator.get_executer( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + num_runs=1, + print_detailed_results=True, + ) + try: + await executer.evaluate() + finally: + result = executer.get_result() + if result is not None: + for eval_set_id, set_result in result.results_by_eval_set_id.items(): + print(f"EvalSet: {eval_set_id}, num_runs: {set_result.num_runs}") + for eval_id, case_results in set_result.eval_results_by_eval_id.items(): + for run_result in case_results: + status = run_result.final_eval_status.value + scores = {m.metric_name: m.score for m in run_result.overall_eval_metric_results} + print(f" case {eval_id}: {status}, scores={scores}") +``` + +#### Result Data Structure + +The hierarchical structure of results is: **EvaluateResult → EvalSetAggregateResult → EvalCaseResult → EvalMetricResult**. + +##### EvaluateResult + +The top-level object obtained by the user via `get_result()`, representing the aggregated results of all eval sets in one evaluation. + +| Field | Type | Description | +| --- | --- | --- | +| results_by_eval_set_id | dict[str, EvalSetAggregateResult] | Key is the eval set ID (eval_set_id), value is the aggregated result for that eval set. | + +##### EvalSetAggregateResult + +| Field | Type | Description | +| --- | --- | --- | +| eval_results_by_eval_id | dict[str, list[EvalCaseResult]] | Key is the case ID (eval_id), value is the list of EvalCaseResults for that case across runs; when num_runs > 1, the list has multiple items. | +| num_runs | int | Number of runs for this eval set, default 1. | + +##### EvalCaseResult + +| Field | Type | Description | +| --- | --- | --- | +| eval_set_id | str | The eval set ID this case belongs to. | +| eval_id | str | Case ID. | +| run_id | int\| None | Run sequence number (1-based), has a value when num_runs > 1. | +| final_eval_status | EvalStatus | The final status of this case in this run: passed / failed / not_evaluated. | +| error_message | str\| None | Error message when inference or evaluation fails. | +| overall_eval_metric_results | list[EvalMetricResult] | Overall results for each metric on this case. | +| eval_metric_result_per_invocation | list[EvalMetricResultPerInvocation] | Per-invocation metric results; each item contains actual_invocation, expected_invocation, eval_metric_results. | +| session_id | str | Session ID used during evaluation. | +| user_id | str\| None | User ID used during evaluation. | +| session_details | Any\| None | Optional session details. | + +##### EvalMetricResult + +Inherits from **EvalMetric**, so in addition to the fields below, it also includes base class fields metric_name, threshold, criterion. + +| Field | Type | Description | +| --- | --- | --- | +| metric_name | str | Metric name (from EvalMetric). | +| threshold | float | Configured pass/fail threshold (from EvalMetric). | +| criterion | dict\| null | Optional evaluation configuration (from EvalMetric). Keys such as `tool_trajectory`, `final_response`, used by corresponding evaluators; sanitized on persistence (e.g., api_key removed). | +| score | float\| None | Score for this metric. | +| eval_status | EvalStatus | Whether this metric passed (1=passed, 2=failed, 3=not_evaluated). | +| details | EvalMetricResultDetails\| None | Optional details (reason, score, rubric_scores; filled by LLM evaluators). | + +##### EvalMetricResultDetails + +| Field | Type | Description | +| --- | --- | --- | +| reason | str\| None | Scoring reason (e.g., from LLM judge). | +| score | float\| None | Score in details. | +| rubric_scores | list[Any]\| None | Per-rubric scores for rubric-based metrics (e.g., LLM rubric's RubricScore). | + +##### EvalMetricResultPerInvocation + +| Field | Type | Description | +| --- | --- | --- | +| actual_invocation | Invocation | Actual trace for this turn. | +| expected_invocation | Invocation\| None | Expected trace for this turn. | +| eval_metric_results | list[EvalMetricResult] | Metric results for this turn. | + +#### Result Persistence + +Pass the parameter **eval_result_output_dir** (string, absolute or relative directory path) when calling **AgentEvaluator.evaluate(...)** or **AgentEvaluator.get_executer(...)**. When provided, the framework uses **LocalEvalSetResultsManager** to write results for each eval set to that directory upon completion; if not provided, results are only aggregated in memory without writing files. + +**Example**: Write results to the `eval_output` directory under the current directory. + +```python +executer = AgentEvaluator.get_executer( + agent_module="agent", + eval_dataset_file_path_or_dir=eval_set_path, + eval_result_output_dir=os.path.join(os.path.dirname(__file__), "eval_output"), +) +await executer.evaluate() +## Results will be written to eval_output//*.evalset_result.json +``` + +#### Persisted File Format + +When **eval_result_output_dir** is provided, the framework calls **LocalEvalSetResultsManager.save_eval_set_result** after each eval set run completes, serializing **EvalSetResult** as JSON to a file. + +##### Directory and File Name + +- **Directory**: `{eval_result_output_dir}/{app_name}/`. The **app_name** comes from the **EvalSet**'s **app_name** field (configurable at the evalset.json root node); if not configured, the default value is `"test_app"`. +- **File name**: `{eval_set_result_name}.evalset_result.json`. The **eval_set_result_name** is generated by `_eval_set_results_manager_utils.create_eval_set_result`: first producing `eval_set_result_id = "{app_name}_{eval_set_id}_{timestamp}"` (timestamp from `time.time()`), then applying `replace("/", "_")` on the id to get **eval_set_result_name** as the file name (see `_sanitize_eval_set_result_name`). When reading, `list_eval_set_results(app_name)` returns a list of file names without the extension (i.e., each eval_set_result_name); passing that string as the second parameter to `get_eval_set_result(app_name, eval_set_result_id)` loads the corresponding file. + +##### File Content Structure + +The file content is a single JSON object, corresponding to **EvalSetResult** (consistent with `_eval_result.EvalSetResult`). The persistence implementation is in `_local_eval_set_results_manager.LocalEvalSetResultsManager.save_eval_set_result`: first `eval_set_result.model_dump_json()` (without `by_alias`), then `json.dumps(json.loads(...), indent=2)` writes to file; therefore JSON keys are model field names (snake_case), and **EvalStatus** is serialized as enum integer values 1, 2, 3. The main fields are as follows. + +| Field | Type | Description | +| --- | --- | --- | +| eval_set_result_id | str | Unique identifier for this result, value is `{app_name}_{eval_set_id}_{timestamp}`. | +| eval_set_result_name | str\| null | Name used for the file name (eval_set_result_id with `/` replaced by `_`), consistent with the file name prefix. | +| eval_set_id | str | Eval set ID. | +| eval_case_results | array | All case results for this eval set run, each item being **EvalCaseResult** in JSON (containing eval_set_id, eval_id, run_id, final_eval_status, overall_eval_metric_results, eval_metric_result_per_invocation, session_id, user_id, etc.). | +| summary | object\| null | **EvalSetResultSummary**: multi-run/multi-case summary, built by the framework when results exist, non-null. Fields described below. | +| creation_timestamp | number | Creation timestamp (float). | + +##### Nested Structures in Persisted Files + +The following structures are consistent with the models in `_eval_result`; persisted keys are snake_case, EvalStatus is 1/2/3. + +**EvalSetResultSummary** (summary object) + +| Field | Type | Description | +| --- | --- | --- | +| overall_status | EvalStatus | Aggregated status across all cases and turns (1/2/3). | +| num_runs | int | Number of runs. | +| run_status_counts | EvalStatusCounts\| null | Status counts per run; null when all are 0. | +| run_summaries | list[EvalSetRunSummary] | Per-run summaries. | +| eval_case_summaries | list[EvalCaseResultSummary] | Cross-run summaries for each case. | + +**EvalStatusCounts** (used for run_status_counts, case_status_counts, status_counts, etc. Generated by `_eval_set_results_manager_utils._normalize_counts`: serialized as null only when passed, failed, and not_evaluated are all 0) + +| Field | Type | Description | +| --- | --- | --- | +| passed | int | Number passed. | +| failed | int | Number failed. | +| not_evaluated | int | Number not evaluated. | + +**EvalSetRunSummary** (each item in run_summaries) + +| Field | Type | Description | +| --- | --- | --- | +| run_id | int | Run sequence number (1-based). | +| overall_status | EvalStatus | Overall status for this run. | +| case_status_counts | EvalStatusCounts\| null | Case status counts for this run. | +| metric_summaries | list[EvalMetricSummary] | Per-metric summaries for this run. | + +**EvalMetricSummary** (each item in metric_summaries) + +| Field | Type | Description | +| --- | --- | --- | +| metric_name | str | Metric name. | +| average_score | float | Average score across samples. | +| eval_status | EvalStatus | Summary status derived from average score and threshold. | +| threshold | float | Threshold. | +| status_counts | EvalStatusCounts\| null | Status counts. | + +**EvalCaseResultSummary** (each item in eval_case_summaries) + +| Field | Type | Description | +| --- | --- | --- | +| eval_id | str | Case ID. | +| overall_status | EvalStatus | Cross-run aggregated status for this case. | +| run_status_counts | EvalStatusCounts\| null | Per-run status counts for this case. | +| metric_summaries | list[EvalMetricSummary] | Cross-run per-metric summaries for this case. | +| run_summaries | list[EvalCaseRunSummary] | Per-run summaries for this case. | + +**EvalCaseRunSummary** (each item in EvalCaseResultSummary.run_summaries) + +| Field | Type | Description | +| --- | --- | --- | +| run_id | int | Run sequence number (1-based). | +| final_eval_status | EvalStatus | Final status for this case in this run. | +| error_message | str\| null | Error message for this run. | +| metric_results | list[EvalMetricRunSummary] | Per-metric results for this run. | + +**EvalMetricRunSummary** (single run, single metric) + +| Field | Type | Description | +| --- | --- | --- | +| metric_name | str | Metric name. | +| score | float | Score for this run. | +| eval_status | EvalStatus | Status for this metric in this run. | +| threshold | float | Threshold. | + +##### Persisted JSON Example + +Below is an example persisted file for a single case, single run, and two metrics. Invocation, Content, and other nested structures are serialized according to their respective models, abbreviated here with `...`. **EvalStatus** enum is persisted as numeric values: 1=passed, 2=failed, 3=not_evaluated; **EvalStatusCounts**'s passed/failed/not_evaluated are integers. + +```json +{ + "eval_set_result_id": "test_app_weather_agent_quickstart_1730123456.78", + "eval_set_result_name": "test_app_weather_agent_quickstart_1730123456.78", + "eval_set_id": "weather_agent_quickstart", + "eval_case_results": [ + { + "eval_set_id": "weather_agent_quickstart", + "eval_id": "simple_weather_001", + "run_id": 1, + "final_eval_status": 1, + "error_message": null, + "overall_eval_metric_results": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": null, + "score": 1.0, + "eval_status": 1, + "details": null + }, + { + "metric_name": "final_response_avg_score", + "threshold": 0.6, + "criterion": null, + "score": 1.0, + "eval_status": 1, + "details": null + } + ], + "eval_metric_result_per_invocation": [ + { + "actual_invocation": { + "invocation_id": "...", + "user_content": {"...": "..."}, + "final_response": {"...": "..."}, + "intermediate_data": {"...": "..."} + }, + "expected_invocation": { + "invocation_id": "e-quick-001", + "user_content": {"...": "..."}, + "final_response": {"...": "..."}, + "intermediate_data": {"...": "..."} + }, + "eval_metric_results": [ + { + "metric_name": "tool_trajectory_avg_score", + "threshold": 0.8, + "criterion": null, + "score": 1.0, + "eval_status": 1, + "details": null + }, + { + "metric_name": "final_response_avg_score", + "threshold": 0.6, + "criterion": null, + "score": 1.0, + "eval_status": 1, + "details": null + } + ] + } + ], + "session_id": "...", + "user_id": "user", + "session_details": null + } + ], + "summary": { + "overall_status": 1, + "num_runs": 1, + "run_status_counts": { + "passed": 1, + "failed": 0, + "not_evaluated": 0 + }, + "run_summaries": [ + { + "run_id": 1, + "overall_status": 1, + "case_status_counts": {"passed": 1, "failed": 0, "not_evaluated": 0}, + "metric_summaries": ["..."] + } + ], + "eval_case_summaries": [ + { + "eval_id": "simple_weather_001", + "overall_status": 1, + "run_status_counts": { + "passed": 1, + "failed": 0, + "not_evaluated": 0 + }, + "metric_summaries": ["..."], + "run_summaries": [ + { + "run_id": 1, + "final_eval_status": 1, + "error_message": null, + "metric_results": ["..."] + } + ] + } + ] + }, + "creation_timestamp": 1730123456.78 +} +``` + +--- + +### Advanced Features + +#### Execution Methods + +Evaluation test cases are asynchronous tests and require `pytest-asyncio`. If the project root's `pyproject.toml` has `[tool.pytest.ini_options]` configured with `asyncio_mode = "auto"`, there is no need to specify an event loop on each test; otherwise, use `@pytest.mark.asyncio` on the test. + +Execute from the directory containing the eval test cases, or specify the test path from the project root. It is recommended to add `-v`, `-s`, `--tb=short`: + +```bash +cd examples/evaluation/quickstart +pytest test_quickstart.py -v --tb=short -s + +## Or from the project root +pytest examples/evaluation/quickstart/test_quickstart.py -v -s +``` + +#### Running a Single Eval Case + +When an eval set contains multiple cases and you only want to run one, you can use the format "file path + colon + case ID" in `eval_dataset_file_path_or_dir`; the framework will only load and execute that case. + +Format: `:`. If the specified `eval_case_id` does not exist in the eval set file, a `ValueError` is raised with a list of existing case IDs in that file. + +```python +test_dir = os.path.dirname(os.path.abspath(__file__)) +eval_set_path = os.path.join(test_dir, "agent", "weather_agent.evalset.json:simple_weather_001") +await AgentEvaluator.evaluate( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + print_detailed_results=True, +) +``` + +#### Multiple Runs (num_runs) + +By default, each eval case runs only once. To observe stability, evaluate randomness, or compute multi-run statistics (e.g., pass@k), configure **num_runs > 1**: the framework will execute N "inference → scoring" cycles for the same eval set, with each run independently invoking the Agent without interference. + +**Configuration Methods** + +- Pass `num_runs=N` in **AgentEvaluator.get_executer()** or **evaluate()**. +- If a `test_config.json` exists in the same directory as the eval set, its **num_runs** will be used as the run count for that eval set (overriding the num_runs passed at invocation time). + +**Example**: Run 3 times and print each run's per-case status + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator + +@pytest.mark.asyncio +async def test_multi_run(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "weather_agent.evalset.json") + + executer = AgentEvaluator.get_executer( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + num_runs=3, + ) + await executer.evaluate() + result = executer.get_result() + if result: + for eval_set_id, agg in result.results_by_eval_set_id.items(): + print(f"EvalSet {eval_set_id}, num_runs={agg.num_runs}") + for eval_id, case_list in agg.eval_results_by_eval_id.items(): + for r in case_list: + print(f" {eval_id} run_id={r.run_id} status={r.final_eval_status}") +``` + +num_runs can also be specified in **test_config.json**. **Priority**: When a `test_config.json` exists in the same directory as the eval set, its **num_runs** takes precedence, overriding the num_runs passed to **get_executer()** / **evaluate()**; if the file does not exist, the num_runs passed at invocation time is used. + +```json +{ + "metrics": ["..."], + "num_runs": 3 +} +``` + +#### pass@k and pass^k + +After multiple runs (num_runs > 1), in addition to per-run pass/fail results, **pass@k** and **pass^k** metrics can be estimated based on "the number of fully-passed runs." Both require obtaining **(n, c)**: **n** is the number of runs, and **c** is the number of runs in the eval set where "all cases in the run passed" (i.e., each run is treated as an "attempt," and only when all cases pass in that run is the attempt considered successful). + +- **pass@k**: The probability that at least one run fully passes when making only **k** attempts. Formula: `1 - C(n-c, k)/C(n, k)`. When k=1, this is an unbiased estimator of the "single-attempt pass rate." Commonly used to measure "whether the model can succeed at least once given k chances." +- **pass^k** (pass to the k-th power): The probability that **k** consecutive runs all fully pass. Formula: `(c/n)^k`. Commonly used to measure stability or the estimated probability of "succeeding all k times." + +**How to obtain (n, c)** + +After running the evaluation and obtaining an **EvaluateResult**, use **AgentEvaluator.parse_pass_nc(result)**: returns `dict[str, PassNC]`, where the key is the eval set ID and the value is **PassNC(n, c)** (the n and c for that eval set). **PassNC** is a named tuple with fields **n** and **c**. + +**How to compute pass@k, pass^k** + +- **AgentEvaluator.pass_at_k(n, c, k)**: Pass the above n, c, and k, returns the pass@k value (0–1). +- **AgentEvaluator.pass_hat_k(n, c, k)**: Pass n, c, and k, returns the pass^k value (0–1). + +**Example**: After multiple runs, compute pass@1, pass@5, and pass^2 for an eval set (aligned with the [pass_at_k](../../../examples/evaluation/pass_at_k/) example; the number of runs can be configured by `num_runs` in the `test_config.json` in the same directory). + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator + +@pytest.mark.asyncio +async def test_pass_at_k(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "weather_agent.evalset.json") + + executer = AgentEvaluator.get_executer( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + print_detailed_results=True, + ) + try: + await executer.evaluate() + finally: + result = executer.get_result() + if result is not None: + nc_by_set = AgentEvaluator.parse_pass_nc(result) + for eval_set_id, nc in nc_by_set.items(): + n, c = nc.n, nc.c + pass_1 = AgentEvaluator.pass_at_k(n, c, 1) + pass_5 = AgentEvaluator.pass_at_k(n, c, 5) + pass_hat_2 = AgentEvaluator.pass_hat_k(n, c, 2) + print( + f"EvalSet {eval_set_id}: n={n}, c={c}, " + f"pass@1={pass_1:.4f}, pass@5={pass_5:.4f}, pass^2={pass_hat_2:.4f}" + ) +``` + +For the complete example, see [examples/evaluation/pass_at_k/](../../../examples/evaluation/pass_at_k/). + +#### Evaluating Agents Not Created by This Framework (call_agent) + +If the Agent under test is not created or managed by this framework (e.g., deployed behind an HTTP/RPC service, invoked via CLI, or wrapped by another framework), and you cannot provide `agent_module` or `runner`, use the **`call_agent`** parameter instead: pass an async function, and the evaluator will call it each turn to obtain the actual response. The rest of the scoring flow remains unchanged. + +**Configuration** + +Pass `call_agent=your_async_fn` in **AgentEvaluator.evaluate()** or **get_executer()**, without passing `agent_module` or `runner`. The signature must be `async def call_agent(query: str) -> str`. + +**Applicable Scenarios** + +Evaluating any callable that cannot be instantiated as this framework's `BaseAgent`: HTTP/RPC remote services, CLI Agents, other frameworks (LangChain / AutoGen / custom), etc. + +**Constraints** + +- `call_agent` must be async (passing a sync function raises `ValueError`) +- `call_agent` is mutually exclusive with `agent_module` / `runner` (passing both raises `ValueError`) +- `call_agent` mode is mutually exclusive with Trace mode (eval set containing trace cases raises `ValueError`) +- `call_agent` mode does not support `tool_trajectory_avg_score` (raises `ValueError`); use `final_response_avg_score`, `llm_final_response`, or `llm_rubric_response` instead +- Multi-turn cases call `call_agent` sequentially per turn; each call corresponds to one `Invocation` + +**Example**: Using Claude Code CLI as an external Agent + +```python +import asyncio +import os +from asyncio.subprocess import PIPE + +from trpc_agent_sdk.evaluation import AgentEvaluator + + +async def call_agent(query: str) -> str: + """Call Claude Code CLI and return its text output.""" + cli_bin = os.getenv("CLAUDE_CODE_BIN", "claude") + cli_args = [cli_bin, "-p", query] + + model_name = os.getenv("CLAUDE_CODE_MODEL") + if model_name: + cli_args.extend(["--model", model_name]) + + proc = await asyncio.create_subprocess_exec(*cli_args, stdout=PIPE, stderr=PIPE) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + raise RuntimeError(stderr.decode("utf-8", errors="ignore").strip()) + + output_text = stdout.decode("utf-8", errors="ignore").strip() + for line in output_text.splitlines(): + if line.strip(): + return line.strip() + return "" + + +# Option A: pass/fail only +await AgentEvaluator.evaluate( + eval_dataset_file_path_or_dir="agent/my_evalset.evalset.json", + call_agent=call_agent, +) + +# Option B: structured results +executer = AgentEvaluator.get_executer( + eval_dataset_file_path_or_dir="agent/my_evalset.evalset.json", + call_agent=call_agent, +) +await executer.evaluate() +result = executer.get_result() # EvaluateResult +``` + +> The example uses `claude` as the default command. If your executable name differs (e.g., `trpc-claudecode` or a custom wrapper), set the `CLAUDE_CODE_BIN` environment variable accordingly. For HTTP service scenarios, simply replace the `call_agent` function body with `aiohttp` / `httpx` calls while keeping the signature `async def call_agent(query: str) -> str`. + +#### Multi-Model Judge (Cross-Model Aggregation) + +A single LLM judge metric can use multiple judge models simultaneously, aggregating their verdicts via `models_aggregator` to reduce single-model variance. Use `judge_models` instead of `judge_model`; the two fields are mutually exclusive. Per-model details are available on `PerInvocationResult.per_model_scores` (a list of `NamedScoreResult`). + +**Configuration** + +In `test_config.json`, for any LLM judge metric's `criterion.llm_judge`, replace `judge_model` with `judge_models` (array), and set `models_aggregator` to choose the aggregation strategy. `parallel` controls execution: `true` (default) calls all models concurrently; `false` calls them sequentially. + +**Applicable Scenarios** + +When higher confidence in judge verdicts is needed (e.g., safety compliance, medical scenarios), or when comparing judgments across different models. + +**Built-in Aggregators** + +| Name | Pass Rule | Overall Score | +| --- | --- | --- | +| `all_pass` (default) | All models pass | min of per-model scores | +| `any_pass` | Any model passes | max of per-model scores | +| `majority_pass` | Strict majority passes (`passed*2 > total`) | `passed_count / total` | +| `avg` | Mean ≥ threshold | mean of per-model scores | +| `weighted_avg` | Weighted mean ≥ threshold | `sum(w*s) / sum(w)` | +| `weighted_majority` | Weighted-passed share ≥ 0.5 | `sum(w where passed) / sum(w)` | + +If a single judge model raises during execution, that model is counted as a non-passing vote; if every model raises, the invocation is reported as `NOT_EVALUATED`. + +**Example**: Two judge models with weighted average aggregation + +```json +{ + "metrics": [ + { + "metric_name": "llm_final_response", + "threshold": 1, + "criterion": { + "llm_judge": { + "judge_models": [ + { + "model_name": "glm-4.7", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}", + "weight": 2.0 + }, + { + "model_name": "gpt-4o", + "api_key": "${TRPC_AGENT_API_KEY}", + "base_url": "${TRPC_AGENT_BASE_URL}", + "weight": 1.0 + } + ], + "models_aggregator": "weighted_avg", + "parallel": true + } + } + } + ] +} +``` + +If a judge model has thinking enabled by default, consider setting `"think": false` on its `JudgeModelOptions`: the judge output is structured JSON, and thinking traces add no value to the final verdict. Disabling thinking significantly reduces token cost and latency. + +**Custom Aggregators** + +Custom aggregators can be registered at runtime and take precedence over the `models_aggregator` name in the criterion: + +```python +from trpc_agent_sdk.evaluation import LLM_EVALUATOR_REGISTRY, ScoreResult + +def my_aggregator(per_model, threshold, weights): + score = sum(s.score or 0.0 for s in per_model) / len(per_model) + return ScoreResult(score=score, reason="custom aggregation") + +LLM_EVALUATOR_REGISTRY.register_models_aggregator("llm_final_response", my_aggregator) +``` + +#### Trace Mode + +In default mode, the eval service actually calls the Agent for inference. If you already have pre-recorded conversation traces (e.g., production logs, historical sessions) and want to only "score" without repeating inference, you can use **Trace mode**: set **eval_mode: "trace"** on the case and provide **actual_conversation**; the eval service will skip inference and directly use that trace for scoring. + +> Note: Trace mode and `call_agent` mode are mutually exclusive; when `call_agent` is provided and the eval set contains trace cases, the framework raises `ValueError` at startup. + +**Configuration Methods** + +- Set **eval_mode**: `"trace"` on the **EvalCase**. +- Provide **actual_conversation** (Invocation array) in the same case as the "actual trace" conversation record, with the same structure as **conversation** (each turn containing user_content, final_response, intermediate_data, etc.). +- Optional: You can still configure **conversation** as expectations for the evaluator to compare "actual vs expected." + +**Applicable Scenarios** + +Replaying existing conversations, offline evaluation, or avoiding repeated Agent and model calls when debugging evaluation flows. + +**`agent_module` is optional** + +`agent_module` tells the framework where to load the Agent from, so it can call the Agent for inference during evaluation. Trace mode no longer calls the Agent, so when **every case in the eval set is in trace mode**, `AgentEvaluator.evaluate()` / `get_executer()` no longer needs `agent_module` and you can simply omit it: + +```python +await AgentEvaluator.evaluate( + eval_dataset_file_path_or_dir=trace_only_eval_set_path, +) +``` + +**Example**: A Trace mode case in the eval set + +```json +{ + "eval_set_id": "my_trace_set", + "eval_cases": [ + { + "eval_id": "replay_001", + "eval_mode": "trace", + "actual_conversation": [ + { + "invocation_id": "inv-1", + "user_content": { + "parts": [{"text": "北京天气"}], + "role": "user" + }, + "final_response": { + "parts": [{"text": "北京晴,25°C"}], + "role": "model" + }, + "intermediate_data": { + "tool_uses": [ + { + "id": "t1", + "name": "get_weather", + "args": {"city": "北京"} + } + ] + } + } + ], + "conversation": [ + { + "invocation_id": "exp-1", + "user_content": { + "parts": [{"text": "北京天气"}], + "role": "user" + }, + "final_response": { + "parts": [{"text": "北京晴,25°C"}], + "role": "model" + }, + "intermediate_data": { + "tool_uses": [ + { + "id": "t1", + "name": "get_weather", + "args": {"city": "北京"} + } + ] + } + } + ] + } + ] +} +``` + +For the complete example, see [examples/evaluation/trace_mode/](../../../examples/evaluation/trace_mode/) + +#### Context Injection + +If you want to inject a fixed context before **each inference turn** of an eval case (such as system prompts, domain knowledge, or constraints), you can configure **context_messages** on that case. The eval service will inject these Contents into the session context before each inference turn when driving the Agent. + +**Configuration Methods** + +Set **context_messages** (Content array) in the **EvalCase**. Each Content has the same structure as messages in the conversation (e.g., parts, role). + +**Applicable Scenarios** + +Injecting uniform instructions, knowledge snippets, or format constraints into test cases without repeating them in every user_content. + +**Example**: Injecting a system instruction into a case in the eval set + +```json +{ + "eval_id": "with_context_001", + "context_messages": [ + { + "parts": [ + { + "text": "You are a weather assistant. Only answer weather-related questions. Keep answers brief." + } + ], + "role": "user" + } + ], + "conversation": [ + { + "invocation_id": "e-1", + "user_content": { + "parts": [{"text": "上海天气怎么样"}], + "role": "user" + }, + "final_response": { + "parts": [{"text": "18°C,晴"}], + "role": "model" + }, + "intermediate_data": { + "tool_uses": ["..."] + } + } + ] +} +``` + +For the complete example, see [examples/evaluation/context_messages/](../../../examples/evaluation/context_messages/) + +#### Concurrent Inference + +During inference, multiple eval cases are executed in parallel. The number of concurrent cases is controlled by **InferenceConfig.parallelism**. When invoked through **AgentEvaluator**, pass **case_parallelism** (integer) in **get_executer()**, **evaluate()**, or **evaluate_eval_set()**; if not provided, the default is used (e.g., 4). Excessive concurrency may trigger QPS/RPM limits on the model or API. + +**Example**: Limit to 2 cases running inference simultaneously + +```python +executer = AgentEvaluator.get_executer( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + case_parallelism=2, +) +await executer.evaluate() +``` + +#### Concurrent Evaluation + +During scoring, multiple inference results are evaluated in parallel. The number of concurrent scoring cases is controlled by **EvaluateConfig.parallelism** (default 4). When invoked through **AgentEvaluator**, pass **case_eval_parallelism** (integer) in **get_executer()**, **evaluate()**, or **evaluate_eval_set()**; if not provided, the default is used (4). When using LLM evaluators, be mindful of the model's concurrency/quota limits. + +**Example**: Limit to 2 cases being scored simultaneously + +```python +executer = AgentEvaluator.get_executer( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + case_eval_parallelism=2, +) +await executer.evaluate() +``` + +#### Callbacks + +During the **inference** and **scoring** phases of evaluation, you can attach custom logic (instrumentation, logging, sampling, reporting, etc.) at 8 lifecycle points by registering hooks via **Callbacks** and passing `callbacks=callbacks` when calling `AgentEvaluator.evaluate()` or `get_executer()`. + +##### Usage Steps + +1. Construct `Callbacks()`, wrap one or more hooks with `Callback(hook_name=function, ...)`, then `callbacks.register("name", callback)`; or for a single point, use `callbacks.register_before_inference_set("name", fn)`, etc. +2. Each hook's signature is `(ctx: dict[str, Any], args: ) -> None | CallbackResult`. The framework defines `CallbackFn` as `(ctx, args) -> Optional[CallbackResult]`; `ctx` is a shared context dictionary within the phase, and `args` are the parameters for the current point (types listed below). To pass data forward within the phase, return `CallbackResult(context={...})`; otherwise return `None`. +3. Call `AgentEvaluator.evaluate(..., callbacks=callbacks)` or `get_executer(..., callbacks=callbacks)` to run the evaluation, and hooks will be invoked at their corresponding points. + +##### 8 Lifecycle Points and Execution Order + +The evaluation first completes the entire **inference phase** (all cases), then runs the **scoring phase**. For a single case, the order is as follows (for multiple cases, case-level points are interleaved, but set-level points occur once each): + +| Point | Trigger Timing | args Type (from `trpc_agent_sdk.evaluation`) | +| --- | --- | --- | +| before_inference_set | Before the inference set starts | BeforeInferenceSetArgs | +| before_inference_case | Before each case's inference starts | BeforeInferenceCaseArgs | +| after_inference_case | After each case's inference ends | AfterInferenceCaseArgs | +| after_inference_set | After the inference set ends | AfterInferenceSetArgs | +| before_evaluate_set | Before the scoring set starts | BeforeEvaluateSetArgs | +| before_evaluate_case | Before each case's scoring starts | BeforeEvaluateCaseArgs | +| after_evaluate_case | After each case's scoring ends | AfterEvaluateCaseArgs | +| after_evaluate_set | After the scoring set ends | AfterEvaluateSetArgs | + +##### Callback args Details + +| args Type | Field | Type / Description | +| --- | --- | --- | +| BeforeInferenceSetArgs | request | InferenceRequest, see table below | +| AfterInferenceSetArgs | request | InferenceRequest | +| | results | list[InferenceResult], inference results for all cases in this set | +| | error | Optional[Exception] | +| | start_time | float | +| BeforeInferenceCaseArgs | request | InferenceRequest | +| | eval_case_id | str | +| | session_id | str | +| AfterInferenceCaseArgs | request | InferenceRequest | +| | result | InferenceResult, inference result for this case, see table below | +| | error | Optional[Exception] | +| | start_time | float | +| BeforeEvaluateSetArgs | request | EvaluateRequest, see table below (no eval_set_id; case count via len(request.inference_results)) | +| AfterEvaluateSetArgs | request | EvaluateRequest | +| | result | Optional[EvalSetRunResult], scoring summary for this set (type is Optional; framework typically passes non-None) | +| | error | Optional[Exception] | +| | start_time | float | +| BeforeEvaluateCaseArgs | request | EvaluateRequest | +| | eval_case_id | str | +| AfterEvaluateCaseArgs | request | EvaluateRequest | +| | inference_result | InferenceResult | +| | result | EvalCaseResult, scoring result for this case; **use result.eval_id for the case id** (this args has no eval_case_id) | +| | error | Optional[Exception] | +| | start_time | float | + +**Nested type fields** (specific contents of the request / result fields above): + +| Type | Common Fields | +| --- | --- | +| InferenceRequest | app_name: str, eval_set_id: str, eval_case_ids: Optional[list[str]], inference_config: InferenceConfig | +| EvaluateRequest | inference_results: list[InferenceResult], evaluate_config: EvaluateConfig | +| InferenceResult | eval_case_id: str, eval_set_id: str, app_name: str, inferences: Optional[list[Invocation]], session_id: Optional[str], status: InferenceStatus, error_message: Optional[str], run_id: Optional[int] | +| EvalCaseResult | eval_id: str, eval_set_id: str, final_eval_status: EvalStatus, overall_eval_metric_results: list[EvalMetricResult], eval_metric_result_per_invocation: list[EvalMetricResultPerInvocation], run_id: Optional[int], session_id: str, user_id: Optional[str], error_message: Optional[str] | +| EvalSetRunResult | app_name: str, eval_set_id: str, eval_case_results: list[EvalCaseResult] | + +##### Passing Data Between Hooks with CallbackResult + +**Purpose**: Within the same phase (inference or scoring), allow earlier hooks to pass data to later hooks—such as run_id, phase name, statistics, etc. + +**How to pass**: In the hook that needs to "hand off data," return `CallbackResult(context={"key": value, ...})`; if nothing needs to be passed, return `None`. + +```python +def before_evaluate_set(ctx: dict, args: BeforeEvaluateSetArgs) -> Optional[CallbackResult]: + # Write: subsequent hooks in the same phase can read from ctx + return CallbackResult(context={"phase": "evaluate", "run_id": "run-001"}) +``` + +**How to receive**: In any hook that executes **later within the same phase**, use `ctx.get("context")` to retrieve the dictionary just passed, then access values by key. + +```python +def after_evaluate_set(ctx: dict, args: AfterEvaluateSetArgs) -> Optional[CallbackResult]: + # Read: phase and run_id written in before_evaluate_set + prev = ctx.get("context") or {} + phase = prev.get("phase", "?") + run_id = prev.get("run_id", "?") + print(f"phase={phase}, run_id={run_id}") + return None +``` + +**Two important notes**: + +- Data is stored in `ctx["context"]`; **do not** use `ctx.get("phase")`—use `(ctx.get("context") or {}).get("phase")`. If multiple hooks return `CallbackResult`, later ones will **entirely overwrite** `ctx["context"]`; to append fields, read first then merge: `prev = ctx.get("context") or {}; return CallbackResult(context={**prev, "new_key": value})`. +- The **inference phase** and **scoring phase** each have their own `ctx` and do not share. Context written by set-level hooks (e.g., before_evaluate_set) propagates to every case-level hook within that phase; context written by case-level hooks is only visible within that case. + +##### Complete Example + +All 8 points logging, with the scoring phase using context to pass `phase` (written in before_evaluate_set, read in after_evaluate_set): + +```python +import os +from typing import Any, Optional + +import pytest +from trpc_agent_sdk.evaluation import ( + AgentEvaluator, + Callbacks, + Callback, + CallbackResult, + BeforeInferenceSetArgs, + AfterInferenceSetArgs, + BeforeInferenceCaseArgs, + AfterInferenceCaseArgs, + BeforeEvaluateSetArgs, + AfterEvaluateSetArgs, + BeforeEvaluateCaseArgs, + AfterEvaluateCaseArgs, +) + + +def before_inference_set( + ctx: dict[str, Any], + args: BeforeInferenceSetArgs, +) -> Optional[CallbackResult]: + print("[callback] inference set started", args.request.eval_set_id, flush=True) + return None + + +def after_inference_set( + ctx: dict[str, Any], + args: AfterInferenceSetArgs, +) -> Optional[CallbackResult]: + n = len(args.results) if args.results else 0 + print("[callback] inference set ended,", n, "cases total", flush=True) + return None + + +def before_inference_case( + ctx: dict[str, Any], + args: BeforeInferenceCaseArgs, +) -> Optional[CallbackResult]: + print("[callback] case inference started", args.eval_case_id, flush=True) + return None + + +def after_inference_case( + ctx: dict[str, Any], + args: AfterInferenceCaseArgs, +) -> Optional[CallbackResult]: + print("[callback] case inference ended", args.result.eval_case_id, flush=True) + return None + + +def before_evaluate_set( + ctx: dict[str, Any], + args: BeforeEvaluateSetArgs, +) -> Optional[CallbackResult]: + n = len(args.request.inference_results) + print("[callback] scoring set started cases=", n, flush=True) + return CallbackResult(context={"phase": "evaluate"}) + + +def after_evaluate_set( + ctx: dict[str, Any], + args: AfterEvaluateSetArgs, +) -> Optional[CallbackResult]: + n = len(args.result.eval_case_results) if args.result else 0 + phase = (ctx.get("context") or {}).get("phase", "?") + print("[callback] scoring set ended,", n, "cases, ctx.phase=", phase, flush=True) + return None + + +def before_evaluate_case( + ctx: dict[str, Any], + args: BeforeEvaluateCaseArgs, +) -> Optional[CallbackResult]: + print("[callback] case scoring started", args.eval_case_id, flush=True) + return None + + +def after_evaluate_case( + ctx: dict[str, Any], + args: AfterEvaluateCaseArgs, +) -> Optional[CallbackResult]: + print("[callback] case scoring ended", args.result.eval_id, flush=True) + return None + +@pytest.mark.asyncio +async def test_with_callbacks(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "callbacks_example.evalset.json") + callbacks = Callbacks() + callbacks.register( + "demo", + Callback( + before_inference_set=before_inference_set, + after_inference_set=after_inference_set, + before_inference_case=before_inference_case, + after_inference_case=after_inference_case, + before_evaluate_set=before_evaluate_set, + after_evaluate_set=after_evaluate_set, + before_evaluate_case=before_evaluate_case, + after_evaluate_case=after_evaluate_case, + ), + ) + await AgentEvaluator.evaluate( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + callbacks=callbacks, + ) +``` + +For the complete runnable example (including all 8 point registrations and order assertions), see [examples/evaluation/callbacks/](../../../examples/evaluation/callbacks/). + +#### Custom Runner + +By default, the eval service uses the built-in Runner and session to drive the Agent. If you already have a **Runner** instance (with its own session service, Agent, or deployment environment) and want to use the same environment for evaluation, you can pass it in: the eval service will prioritize using that Runner for inference, while scoring logic is still handled by the framework. If the case has **session_input** configured, the Runner's session will be updated accordingly. + +**Configuration Methods** + +Pass **runner=** your **Runner** instance in **AgentEvaluator.get_executer()** or **evaluate_eval_set()**. + +**Applicable Scenarios** + +Reusing an existing session service, specific Agent deployment, or middleware (such as unified authentication, logging), while the evaluation flow and scoring logic are still handled uniformly by the framework. + +**Example**: Running evaluation with a custom Runner (aligned with the [custom_runner](../../../examples/evaluation/custom_runner/) example) + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from agent import root_agent + +@pytest.mark.asyncio +async def test_evaluate_with_custom_runner(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + eval_set_path = os.path.join(test_dir, "agent", "custom_runner_example.evalset.json") + + session_service = InMemorySessionService() + runner = Runner( + app_name="weather_agent", + agent=root_agent, + session_service=session_service, + ) + await AgentEvaluator.evaluate( + agent_module="agent", + agent_name="weather_agent", + eval_dataset_file_path_or_dir=eval_set_path, + runner=runner, + ) +``` + +For the complete example, see [examples/evaluation/custom_runner/](../../../examples/evaluation/custom_runner/). + +#### Shared Configuration (eval_metrics_file_path_or_dir) + +By default, every eval set needs a `test_config.json` placed in its **own directory**, which the framework loads automatically. When multiple eval sets need to use the same metrics and thresholds, copying `test_config.json` into every directory is redundant and prone to drift. You can extract the config into a single shared location and point to it via `eval_metrics_file_path_or_dir` at call time. The framework will then **ignore the same-directory convention** and apply this shared config to every eval set. + +**Comparison**: in the default layout each eval set needs its own `test_config.json`; in the shared layout there is only one. + +``` +## Default (same-directory) ## Shared (eval_metrics_file_path_or_dir) +project/ project/ +└── eval_data/ ├── shared_metrics.json ← shared config + ├── weather/ └── eval_data/ + │ ├── weather.evalset.json ├── weather/weather.evalset.json + │ └── test_config.json ├── booking/booking.evalset.json + └── booking/ └── search/search.evalset.json + ├── booking.evalset.json + └── test_config.json +``` + +**Configuration** + +Pass `eval_metrics_file_path_or_dir` to `AgentEvaluator.evaluate()` / `get_executer()`: + +- A **file path** (`.json`): loaded directly as the shared configuration; +- A **directory path**: the framework looks up `*.json` in that directory **non-recursively**, and exactly one must be present; otherwise it raises `FileNotFoundError` (zero matches) or `ValueError` (more than one); +- Omitted or `None`: keep the default behavior—load `test_config.json` from each eval set's own directory. + +**Applicable Scenarios** + +Multiple eval sets sharing the same metrics and thresholds; switching thresholds per environment (dev / staging / prod) in CI; eval sets generated by other tools (WebUI, log replayers, etc.) where maintaining a per-directory `test_config.json` is inconvenient. + +**Example**: point all eval sets in the right-hand layout above to `shared_metrics.json` + +```python +import os +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator + +@pytest.mark.asyncio +async def test_with_shared_metrics(): + project_dir = os.path.dirname(os.path.abspath(__file__)) + await AgentEvaluator.evaluate( + agent_module="agent", + eval_dataset_file_path_or_dir=os.path.join(project_dir, "eval_data"), + eval_metrics_file_path_or_dir=os.path.join(project_dir, "shared_metrics.json"), + ) +``` + + +## Using WebUI for Agent Evaluation + +This document describes how to use the **WebUI** for Agent evaluation. The WebUI provides a visual evaluation interface that supports interactive creation of eval cases, running evaluations, and viewing results. + +**Note**: The WebUI functionality of this framework is implemented by integrating with [adk-web](https://github.com/google/adk-web). adk-web is a web interface provided by the Google ADK project for visually managing Agents and running evaluations. + +### Installation + +Using WebUI for Agent evaluation requires the following dependencies: + +```bash +pip install -e ".[eval]" +``` + +### Starting the Services + +#### 1. Start the Debug Server + +```bash +## Recommended: explicitly specify IP and port +python -m trpc_agent_sdk.server.debug.server --agents ./agents --host 0.0.0.0 --port 8000 + +## Or use defaults (local access only) +python -m trpc_agent_sdk.server.debug.server --agents ./agents +``` + +**Parameter description**: +- `--host`: Server address (default: 127.0.0.1) + - Use `0.0.0.0` to allow access from other machines + - Use `127.0.0.1` for local-only access +- `--port`: Server port (default: 8000) +- `--agents`: Directory containing Agent files (default: ./agents) + +**Important notes**: +- **It is recommended to explicitly specify `--host` and `--port`**, especially when access from other machines is needed + +#### 2. Start the WebUI (adk-web) + +The WebUI uses the [adk-web](https://github.com/google/adk-web) project, an open-source Agent management interface. The startup steps are as follows: + +```bash +git clone https://github.com/google/adk-web.git +cd adk-web +npm install +## --backend points to the debug server address (must match the server address started above) +npm run serve --backend=http://127.0.0.1:8000 +``` + +**Notes**: +- adk-web is a standalone frontend project that connects to our Debug Server via the `--backend` parameter +- The `--backend` parameter must match the Debug Server's address and port + - If the Debug Server uses `--host 0.0.0.0 --port 8000`, use `http://:8000` + - If the Debug Server uses defaults, use `http://127.0.0.1:8000` +- The Debug Server implements APIs compatible with adk-web, so it can be used directly +- adk-web runs on `http://localhost:8080` by default + +Access the WebUI at: `http://localhost:8080` + +### File Organization + +#### Important: File Naming and Organization Conventions + +The WebUI evaluation has strict requirements for file naming and organization. Please follow these conventions: + +**Core principles**: +1. **`root_agent.name` must match the directory name or file name** (without the `.py` extension) +2. **`app_name` must exactly match `root_agent.name`** (case-sensitive) +3. **Eval Set files must be placed in the `{agents_dir}/{app_name}/` directory** +4. **All IDs (`eval_set_id`, `eval_id`) may only contain letters, digits, and underscores** + +#### Agent File Organization + +Agent files can be organized in the following three ways: + +**Option 1: Single Agent (recommended for simple scenarios)** + +``` +agents/ +└── agent.py # Contains root_agent +``` + +**Option 2: Multiple Agents (each in a subdirectory, recommended)** + +``` +agents/ +├── agent/ +│ └── agent.py # Contains root_agent, name="agent" +└── weather_agent/ + └── agent.py # Contains root_agent, name="weather_agent" +``` + +**Option 3: Multiple Agents (each as a standalone Python file)** + +``` +agents/ +├── agent.py # Contains root_agent, name="agent" +└── weather_agent.py # Contains root_agent, name="weather_agent" +``` + +**Key requirements**: +- The Agent must export a `root_agent` variable +- `root_agent.name` must match the directory name or file name (without the `.py` extension) +- For example: if the subdirectory is `agent/`, then `root_agent.name` must be `"agent"` + +#### Eval Set File Organization + +Eval Set files must be placed in one of the following locations: + +**Standard path (recommended)**: +``` +agents/ +└── {app_name}/ + └── {eval_set_id}.evalset.json +``` + +**Key requirements**: +- `app_name` must **exactly match** the Agent's `root_agent.name` +- `eval_set_id` is the file name (without the `.evalset.json` extension) +- The file extension must be `.evalset.json` + +**Example**: + +Assuming the Agent is defined as follows: +```python +## agents/agent/agent.py +root_agent = LlmAgent( + name="agent", # Must match the directory name + ... +) +``` + +Then the Eval Set file should be placed in that directory: +``` +agents/ +└── agent/ + └── agent.evalset.json # eval_set_id = "agent", matches the file name +``` + +#### Eval Set File Content Naming + +In the Eval Set JSON file, pay attention to the following naming: + +```json +{ + "eval_set_id": "agent", + "name": "Book Finder Evaluation", + "description": "Test book finding functionality", + "eval_cases": [ + { + "eval_id": "session_001_library_available", + "conversation": [...], + "session_input": { + "app_name": "agent", + "user_id": "user", + "state": {} + } + } + ] +} +``` + +**Key field descriptions**: +- `eval_set_id`: Must match the file name (without the `.evalset.json` extension) +- `session_input.app_name`: Must match `root_agent.name` +- `eval_id`: Unique identifier for each eval case, must be unique within the same eval set + +### Usage Flow + +#### 1. Prepare the Agent + +Ensure Agent files are correctly organized and `root_agent.name` is set properly: + +```python +## agents/agent/agent.py +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel + +root_agent = LlmAgent( + name="agent", # Must match the directory name; will be used as app_name + model=OpenAIModel(...), + instruction="You are a book finder assistant", + tools=[...], +) +``` + +#### 2. Create an Eval Set + +In the WebUI: +1. Select the corresponding Agent (`app_name` corresponds to `root_agent.name`) +2. Create a new Eval Set +3. The system will automatically create a `{eval_set_id}.evalset.json` file in the `{agents_dir}/{app_name}/` directory + +**Note**: `eval_set_id` must comply with naming conventions: +- Only contains letters (a-z, A-Z), digits (0-9), and underscores (_) +- Cannot contain spaces, hyphens, dots, or other special characters +- Regular expression: `^[a-zA-Z0-9_]+$` + +#### 3. Add Eval Cases + +There are two ways to add eval cases: + +**Option 1: Add from a conversation (recommended)** +1. Chat with the Agent in the WebUI +2. Select the conversation to add +3. Click "Add to Eval Set" +4. Enter the `eval_id` (unique case identifier) +5. The system will automatically convert the conversation into an eval case + +**Option 2: Manually edit the JSON file** +Directly edit the `{eval_set_id}.evalset.json` file to add `eval_cases`. + +#### 4. Run Evaluation + +Steps to run evaluation in the WebUI: + +1. **Select Eval Set**: Choose the Eval Set to evaluate from the left panel +2. **Select eval cases**: Check the cases to evaluate (or select all) +3. **Configure eval metrics**: Set evaluation metrics and thresholds + - `tool_trajectory_avg_score`: Tool call trajectory match score (recommended threshold: 0.8) + - `response_match_score`: Response match score (recommended threshold: 0.5) +4. **Run evaluation**: Click the "Run Evaluation" button +5. **View results**: View evaluation results and detailed reports on the right panel + +![Evaluation run interface](../assets/imgs/eval_run.png) + +**Evaluation run interface description**: +- **Left panel**: Displays available Eval Sets and eval case list; cases can be checked for evaluation +- **Middle configuration area**: Shows evaluation configuration, including metric selection and threshold settings +- **Right result area**: Shows evaluation results, including pass/fail status, score details, and comparison information + +#### 5. View Evaluation Traces + +After evaluation completes, you can view detailed execution trace information: + +![Evaluation trace interface](../assets/imgs/eval_trace.png) + +**Evaluation trace interface description**: +- **Execution trace**: Shows the complete execution process for each eval case +- **Tool calls**: Displays the tools called by the Agent and their parameters +- **Response comparison**: Compares expected and actual responses, highlighting differences +- **Score details**: Shows detailed scores for each metric and pass/fail status + +### Complete Example + +Complete example: [examples/evaluation/webui/](../../../examples/evaluation/webui/). When passing `--agents` pointing to this directory, its subdirectory `agent/` constitutes an application (directory name must match `root_agent.name`). + +**File structure**: + +``` +webui/ # --agents points to this directory +├── agent/ # Subdirectory name = root_agent.name +│ ├── agent.py +│ ├── agent.evalset.json +│ ├── config.py, prompts.py, tools.py, test_config.json, ... +│ └── __init__.py +├── run_agent.py +├── test_book_finder.py +└── README.md +``` + +**agent/agent.py** (excerpt): +```python +root_agent = LlmAgent( + name="agent", # Matches the directory name agent/ + ... +) +``` + +**agent/agent.evalset.json**: `eval_set_id` matches the file base name as `"agent"`, `session_input.app_name` is `"agent"`. + +Start the service: +```bash +python -m trpc_agent_sdk.server.debug.server --agents examples/evaluation/webui +``` + +After starting, in the WebUI: +- The Agent list shows `agent` +- The Eval Set is loaded from `agent/agent.evalset.json` +- You can run evaluations and view results diff --git a/docs/mkdocs/en/filter.md b/docs/mkdocs/en/filter.md new file mode 100644 index 000000000..3d478506d --- /dev/null +++ b/docs/mkdocs/en/filter.md @@ -0,0 +1,262 @@ +# Filters + +trpc_agent provides a powerful Filter mechanism to intercept and process the request-response flow. Filters can handle requests at different stages, including tool invocations, model invocations, and agent invocations, offering developers flexible extensibility. + +## Core Features of Filter + +- **Request Interception**: Preprocessing and validation before request execution +- **Response Handling**: Post-processing and result modification after request execution +- **Streaming Support**: Real-time processing of streaming responses +- **Chain Invocation**: Sequential execution of multiple filters + +The Filter base class provides two interception methods for regular invocations and streaming invocations respectively: + +```python +class BaseFilter(FilterABC): + async def run_stream(self, ctx: AgentContext, req: Any, + handle: FilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + pass + + async def run(self, ctx: AgentContext, req: Any, handle: FilterHandleType) -> FilterResult: + pass +``` +- `run`: Coroutine function +- `run_stream`: Async generator for implementing streaming behavior + +**For different types of Filters, users need to inherit and override different methods** + +## Basic Usage of Filter + +trpc_agent provides three types of filters, each for a different processing stage: + +### ToolFilter + +* **Scope**: Before and after tool invocations +* **Trigger**: When an Agent invokes a tool +* **Registration**: Using the `@register_tool_filter` decorator + +```python +@register_tool_filter("tool_filter") +class ToolFilter(BaseFilter): + """Tool filter example""" + + async def run(self, ctx: AgentContext, req: Any, handle: FilterHandleType) -> FilterResult: + print(f"\n\n==== run tool filter run start ===") + # .. run before + rsp = await handle() + # .. run after + print(f"\n\n==== run tool filter run end ===") + return rsp + + +# Usage +@register_tool("get_weather", filters_name=["tool_filter"]) +async def get_weather(location: str) -> str: + """Get weather information for a location. + + Args: + location: The location to get weather for + + Returns: + Weather information string + """ + return f"The weather in {location} is sunny with 72°F temperature." +``` + +Notes: + +- Only the `run` method should be overridden here, as the framework invokes it as a coroutine. Overriding `run_stream` has no effect. + +### ModelFilter + +* **Scope**: Before and after model invocations and streaming responses +* **Trigger**: When an Agent invokes an LLM model +* **Registration**: Using the `@register_model_filter` decorator + +```python +@register_model_filter("model_filter") +class ModelFilter(BaseFilter): + """Model filter example""" + + async def run_stream(self, ctx: AgentContext, req: Any, + handle: FilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + print(f"\n\n==== run model filter run_stream start ===") + async for event in handle(): + print(f"\n\n==== run model filter run_stream event ===") + yield event + if not event.is_continue: + print(f"\n\n==== run model filter run_stream end ===") + return + print(f"\n\n==== run model filter run_stream end ===") + +# Usage +model = OpenAIModel( + model_name="deepseek-chat", + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url="https://api.deepseek.com/v1", + filters_name=["model_filter"], +) +``` + +Notes: + +- Only the `run_stream` method should be overridden here, as the framework invokes it as an async generator. Overriding `run` has no effect. + +### AgentFilter + +* **Scope**: Before and after agent invocations +* **Trigger**: When the Runner invokes an Agent +* **Registration**: Using the `@register_agent_filter` decorator + +```python +@register_agent_filter("agent_filter") +class AgentFilter(BaseFilter): + """Agent filter example""" + + async def run_stream(self, ctx: AgentContext, req: Any, + handle: FilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + print(f"\n\n==== run agent filter run_stream start ===") + async for event in handle(): + print(f"\n\n==== run agent filter run_stream event ===") + yield event + if not event.is_continue: + print(f"\n\n==== run agent filter run_stream end ===") + return + print(f"\n\n==== run agent filter run_stream end ===") + +# Usage +agent = LlmAgent( + name="assistant", + model=model, # Use the configured Deepseek model + instruction="You are a helpful assistant with access to weather and calculation tools.", + filters_name=["agent_filter"], +) + +``` + +Notes: + +- Only the `run_stream` method should be overridden here, as the framework invokes it as an async generator. Overriding `run` has no effect. + + +## Usage of Callback + +In addition to explicitly registering Filter classes, the framework also supports registering callback functions to execute user-defined callbacks. Currently, the framework supports setting callback functions for the Tool, Model, and Agent modules. Since callbacks share the same characteristics as Filters, the internal Callback implementation is based on the Filter mechanism. + +### Agent Callback Usage + +```python +async def before_agent_callback(context: InvocationContext): + """Triggered before Agent execution. Returning a non-null value will skip the Agent invocation and use the returned value as the result directly.""" + print(f'@before_agent_callback context: {type(context)}') + return None + + +async def after_agent_callback(context: InvocationContext): + """Triggered after Agent execution. Can be used for logging, modifying output results, and other post-processing logic.""" + print(f'@after_agent_callback context: {type(context)}') + return None + + +agent = LlmAgent( + # ... + before_agent_callback=before_agent_callback, # Callback before Agent execution; returns non-null to skip Agent invocation + after_agent_callback=after_agent_callback, # Callback after Agent execution +) +``` +Represents callback functions executed before and after each Agent invocation. + +- before_agent_callback: If the return value is non-null, the agent will not be invoked. Under normal circumstances, this function is called once per agent invocation. +- after_agent_callback: Under normal circumstances, this function is called once per agent invocation. + +### Model Callback Usage + +```python +async def before_model_callback(context: InvocationContext, llm_request: LlmRequest): + print(f'@before_model_callback context: {type(context)}, llm_request: {type(llm_request)}') + return None + + +async def after_model_callback(context: InvocationContext, llm_response: LlmResponse): + print(f'@after_model_callback context: {type(context)}, llm_response: {type(llm_response)}') + return None + + +agent = LlmAgent( + # ... + before_model_callback=before_model_callback, + after_model_callback=after_model_callback, +) +``` +Represents callback functions executed before and after each model invocation. + +- before_model_callback: Callback function executed before each model run. If the return value is non-null, the model function will not be invoked. This function is called once per model invocation. +- after_model_callback: Called once per streaming result of each model run. If there are many streaming data chunks, this function is called once per streaming result. + +Currently, only `LlmAgent` has the before_model_callback and after_model_callback methods. + + +### Tool Callback Usage + +```python +def before_tool_callback(context: InvocationContext, tool: BaseTool, args: dict, response: Any): + print(f'@before_tool_callback context: {type(context)}, tool: {type(tool)}, args: {type(args)}, response: {type(response)}') + + +def after_tool_callback(context: InvocationContext, tool: BaseTool, args: dict, response: Any): + print(f'@after_tool_callback context: {type(context)}, tool: {type(tool)}, args: {type(args)}, response: {type(response)}') + + +agent = LlmAgent( + # ... + before_tool_callback=before_tool_callback, + after_tool_callback=after_tool_callback, +) +``` +Represents callback functions executed before and after each tool run. + +- before_tool_callback: Callback function executed before each tool run. If the return value is non-null, the tool will not be invoked. This function is called once per tool invocation. +- after_tool_callback: Callback function executed after each tool run. This function is called once per tool invocation. + +Currently, only `LlmAgent` has the before_tool_callback and after_tool_callback methods. + + +## Filter Lifecycle + +Each Filter has a complete lifecycle management: + +1. **Initialization**: The Filter instance is created and registered +2. **Before Processing**: Preprocessing before request execution +3. **Execution Phase**: Actual business logic execution +4. **_after_every_stream**: Post-processing for each streaming response (streaming scenarios only) +5. **After Processing**: Post-processing after request execution + +## Practical Examples + +For complete usage examples, see: +- [examples/filter_with_agent/](../../../examples/filter_with_agent/) +- [examples/filter_with_model/](../../../examples/filter_with_model/) +- [examples/filter_with_tool/](../../../examples/filter_with_tool/) + + +## FAQ + +### Q: How to exit immediately while streaming response data midway? + +The solution is as follows: + +```python +@register_agent_filter("agent_filter") +class AgentFilter(BaseFilter): + """Agent filter example""" + + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult): + print(f"==== Before agent invocation processing ====") + print(f"Request: {req}, Context: {type(ctx).__name__}") + # Set is_continue to False to interrupt the stream immediately + rsp.is_continue = False + + async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult): + print(f"==== After agent invocation processing ====") + print(f"Request: {req}, Context: {type(ctx).__name__}") +``` diff --git a/docs/mkdocs/en/graph.md b/docs/mkdocs/en/graph.md new file mode 100644 index 000000000..83ed3038a --- /dev/null +++ b/docs/mkdocs/en/graph.md @@ -0,0 +1,723 @@ +# Graph + +## Introduction + +In scenarios that require Agents to perform predictable tasks, workflows are typically orchestrated through Graphs. tRPC-Agent-Python now provides Graph capabilities that support not only orchestrating custom business Nodes, but also orchestrating various framework components (Agents, MCP, knowledge bases, code executors, etc.), enabling developers to quickly build workflows tailored to their use cases based on the framework's existing ecosystem. + +## Architecture Design + +As shown below, users build graphs through the Graph API provided by the framework. The framework's Graph supports general-purpose graph construction and allows integrating existing framework components or custom Agents into the graph. Graph execution is handled by GraphAgent, which wraps LangGraph as the underlying graph execution engine, making it production-ready. + + + +## Environment Setup + +It is recommended to configure your environment with the following constraints: + +- **Custom nodes must be defined using `async def` to prevent issues caused by mixing synchronous and asynchronous code (e.g., blocking the EventLoop)** +- **Python 3.12**: *This constraint is imposed by the graph execution engine LangGraph. The Graph engine wrapper requires nodes to stream various information during execution, a capability Python supports on 3.11 and above.* +- **LangGraph version 1.0.x stable release is recommended** + + +## Quick Start + +The following example demonstrates how to build and run a Graph: using **add_conditional_edges** to route to different nodes (add_node, add_llm_node, add_agent_node) based on conditions. + +```python +import os +from typing import Any + +from typing_extensions import Annotated + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.dsl.graph import ( + GraphAgent, + NodeConfig, + STATE_KEY_LAST_RESPONSE, + STATE_KEY_USER_INPUT, + State, + StateGraph, + append_list, +) + + +# 1. Define the graph state: inherit from State, with business fields + Reducer fields +class DemoState(State): + tool_result: str + agent_reply: str + # append_list reducer: automatically appends when multiple nodes write, without overwriting + node_execution_history: Annotated[list[dict[str, Any]], append_list] + + +# 2. Define a regular function node (must be async def) +async def hello(state: DemoState) -> dict[str, Any]: + """Entry node: prints a welcome message, the next hop is determined by conditional edges""" + print("Hello from graph") + return {} + + +# 3. Define a routing function: returns different route keys based on user input +def route_choice(state: DemoState) -> str: + text = state[STATE_KEY_USER_INPUT].strip().lower() + if "weather" in text: + return "llm" # Weather-related → route to llm_node + return "agent" # Others → route to agent_node + + +# 4. Define an output formatting node: writes upstream results to last_response for Runner to return +async def format_output(state: DemoState) -> dict[str, Any]: + content = state.get("agent_reply") or state.get("tool_result") or "(No content)" + return {STATE_KEY_LAST_RESPONSE: content} + + +# 5. Define a tool function for model function_call invocation in llm_node +async def weather_tool(location: str) -> dict[str, str]: + return {"location": location, "weather": "sunny"} + + +def create_agent() -> GraphAgent: + # Initialize the model (configured via environment variables) + model = OpenAIModel( + model_name=os.getenv("TRPC_AGENT_MODEL_NAME", "deepseek-chat"), + api_key=os.getenv("TRPC_AGENT_API_KEY", ""), + base_url=os.getenv("TRPC_AGENT_BASE_URL", ""), + ) + weather = FunctionTool(weather_tool) + tools = {"weather_tool": weather} + + # Build a sub-Agent to be integrated into the graph as an agent_node + delegate_agent = LlmAgent( + name="query_orchestrator", + description="Agent called by graph agent_node", + model=model, + instruction="Answer user query briefly.", + generate_content_config=GenerateContentConfig( + temperature=0.2, + max_output_tokens=200, + ) + ) + + # Build the graph + graph = StateGraph(DemoState) + + # Regular function node: entry point, routes via conditional edges after printing + graph.add_node( + name="hello", + action=hello, + config=NodeConfig(name="hello", description="Say hello and route by user input"), + ) + + # LLM node: directly invokes the model, with a built-in loop for function_call → tool execution → backfill + graph.add_llm_node( + name="plan_with_llm", + model=model, + instruction="If weather is asked, call weather_tool; otherwise answer directly.", + tools=tools, + tool_parallel=False, + max_tool_iterations=4, + generation_config=GenerateContentConfig( + temperature=0.2, + max_output_tokens=200, + ), + config=NodeConfig(name="plan_with_llm", description="LLM node"), + ) + + # Agent node: integrates an existing LlmAgent as a node in the graph + graph.add_agent_node( + node_id="delegate_to_agent", + agent=delegate_agent, + config=NodeConfig(name="delegate_to_agent", description="Agent node"), + ) + + # Output formatting node + graph.add_node( + name="format_output", + action=format_output, + config=NodeConfig(name="format_output", description="Build final response"), + ) + + # Define edges + graph.set_entry_point("hello") # START → hello + graph.set_finish_point("format_output") # format_output → END + + # Conditional edges: routes to different nodes based on route_choice return value after hello + graph.add_conditional_edges( + source="hello", + path=route_choice, + path_map={"llm": "plan_with_llm", "agent": "delegate_to_agent"}, + ) + graph.add_edge(start="plan_with_llm", end="format_output") + graph.add_edge(start="delegate_to_agent", end="format_output") + + # Compile the graph and wrap it as a GraphAgent for Runner execution + return GraphAgent( + name="graph_demo", + description="Simple graph build demo", + graph=graph.compile(), + ) +``` + +Execution method (Runner): + +```python +import asyncio +import uuid + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content, Part + + +async def main() -> None: + app_name = "graph_demo_app" + user_id = "demo_user" + session_id = str(uuid.uuid4()) + + session_service = InMemorySessionService() + await session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + state={}, + ) + + runner = Runner( + app_name=app_name, + agent=create_agent(), + session_service=session_service, + ) + + content = Content(parts=[Part.from_text(text="What's the weather in Seattle?")]) + async for event in runner.run_async(user_id=user_id, session_id=session_id, new_message=content): + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + + await runner.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +**Complete Example:** +See [examples/graph](../../../examples/graph/README.md). This example provides a full demonstration of integrating various framework components, as shown below: + +```mermaid +graph TD + START --> extract + extract --> decide_from_query + decide_from_query -->|preview| preview + decide_from_query -->|summarize| summarize_llm_node + decide_from_query -->|tool| request_stats_llm_node + decide_from_query -->|subgraph| delegate_agent_node + decide_from_query -->|llm_agent| query_orchestrator_agent + decide_from_query -->|code| code_exec_code_node + decide_from_query -->|mcp| prepare_mcp_request + decide_from_query -->|knowledge| knowledge_search_node + preview --> format_output + summarize_llm_node --> format_output + request_stats_llm_node --> format_output + delegate_agent_node --> format_output + query_orchestrator_agent -.calls weather_tool.-> weather_tool_node + query_orchestrator_agent -.transfer_to_agent.-> domain_explainer_agent + query_orchestrator_agent --> format_output + code_exec_code_node --> format_output + prepare_mcp_request --> mcp_call + mcp_call --> format_output + knowledge_search_node --> format_output + format_output --> END +``` + +Input examples: + +- subgraph: Please respond in a friendly tone. → Triggers the subgraph agent_node +- llm_agent: What's the weather in Seattle? → Triggers query_orchestrator_agent and invokes weather_tool (returns sunny as a fixed response) +- llm_agent: child: What is retrieval augmented generation? → Triggers query_orchestrator_agent, which delegates to its child domain_explainer_agent +- tool: Count words for this text. → Triggers an llm_node with tool invocation capability +- code: run python analysis → Triggers code_node, executing the built-in Python statistics script +- mcp: {"operation": "add", "a": 3, "b": 5} → Triggers mcp_node, invoking the local MCP Server's calculate tool via stdio +- knowledge: What is retrieval augmented generation? → Triggers knowledge_node, searching the knowledge base (requires `ENABLE_KNOWLEDGE` to be enabled) +- Long text (40+ words) → Triggers the summarize LLM node +- Short text → Triggers preview (streams output via EventWriter) + +Multi-turn conversation example: [examples/graph_multi_turns](../../../examples/graph_multi_turns/README.md) + +## StateGraph API Reference + +The graph supports integrating various framework components through different `add_*` methods, allowing **models, tools, Agents, code execution, knowledge base retrieval, MCP tools**, and more to be incorporated into the graph, as shown below: + + +| Method | Purpose | Common Scenarios | +| --- | --- | --- | +| add_node(name, action, ...) | Add a general-purpose async function node | Business logic, data processing, pre-routing processing | +| add_llm_node(name, model, instruction, ...) | Add a node that directly invokes a model (with optional tool loop) | Classification, rewriting, summarization, tool-augmented QA | +| add_code_node(name, code_executor, code, language, ...) | Add a code execution node | Execute Python/Shell scripts and write results back to state | +| add_knowledge_node(name, query, tool, ...) | Add a knowledge retrieval node | RAG retrieval, pre-processing for knowledge base QA | +| add_mcp_node(name, mcp_toolset, selected_tool_name, req_src_node, ...) | Add an MCP tool invocation node | Invoke external MCP Server tools | +| add_agent_node(node_id, agent, ...) | Add a sub-Agent node | Multi-Agent collaboration, subprocess delegation | +| add_edge(start, end) | Add a static directed edge | Fixed execution path | +| add_conditional_edges(source, path, path_map) | Add a conditional routing edge | Intent routing, multi-branch workflows | +| set_entry_point(key) | Set the entry node (START -> key) | Specify workflow entry point | +| set_finish_point(key) | Set the finish node (key -> END) | Specify workflow exit point | +| compile(memory_saver_option=...) | Compile the graph and return an executable object | Final step before Runner execution | + +The following sections describe how to add each type of node, applicable scenarios, and commonly used options. + +### add_node + +Scenario: Integrate a general-purpose async function node (pure business logic, data processing, routing decisions, etc.). + +```python +graph.add_node( + name="extract", + action=extract_document, + config=NodeConfig(name="extract", description="Extract input"), +) +``` + +Common options: +- name: Node ID (used for edge connections, routing, and callback identification) +- action: Must be `async def` +- config: Common configuration (name / description / metadata) +- callbacks: Set callbacks for the current node + +### add_llm_node + +Scenario: Directly invoke a model within a node; if tools are configured, the node will complete the function_call → tool execution → backfill function_response → re-invoke model loop within the same llm_node. + +```python +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import GenerateContentConfig + +async def my_tool(query: str) -> dict[str, str]: + return {"result": f"Processed: {query}"} + +tools = {"my_tool": FunctionTool(my_tool)} + +graph.add_llm_node( + name="classifier", + model=model, + instruction="Classify user intent into one label.", + tools=tools, + tool_parallel=False, + max_tool_iterations=4, + generation_config=GenerateContentConfig( + temperature=0.1, + max_output_tokens=64, + ), + config=NodeConfig(name="classifier", description="Intent classifier"), +) +``` + +Common options: +- tools: Tool dictionary (`{tool_name: tool_instance}`), pass `{}` when not needed +- tool_parallel: Whether multiple tool calls within the same round execute in parallel +- max_tool_iterations: Maximum number of model→tool loop iterations within a single llm_node +- generation_config: Model configuration (temperature, max_output_tokens, etc.) +- config / callbacks: Same as add_node + +### add_code_node + +Scenario: Execute a static code snippet (e.g., Python/Shell) within the graph, with results written to the built-in state; use `STATE_KEY_NODE_RESPONSES[name]` to retrieve the node's output. + +```python +from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor + +graph.add_code_node( + name="run_script", + code_executor=UnsafeLocalCodeExecutor(timeout=30, work_dir="", clean_temp_files=True), + code="result = 1 + 1", + language="python", + config=NodeConfig(name="run_script", description="Run inline code"), +) +``` + +Parameter descriptions: +- code_executor: A `BaseCodeExecutor` instance (e.g., `UnsafeLocalCodeExecutor`, `ContainerCodeExecutor`), constructed by the caller +- code: Source code to execute +- language: `python` / `bash` / `sh` +- config / callbacks: Same as add_node + +### add_knowledge_node + +Scenario: Integrate a knowledge retrieval node into the graph, using an existing `LangchainKnowledgeSearchTool` for retrieval, with results written to state. + +```python +from trpc_agent_sdk.server.knowledge.tools import LangchainKnowledgeSearchTool +from trpc_agent_sdk.server.knowledge.trag_adapter import TragAuthParams +from trpc_agent_sdk.server.knowledge.trag_adapter import TragDocumentLoader +from trpc_agent_sdk.server.knowledge.trag_adapter import TragDocumentLoaderParams +from trpc_agent_sdk.server.knowledge.trag_knowledge import TragKnowledge + + +def _create_trag_knowledge(auth_params: TragAuthParams) -> TragKnowledge: + document_loader = TragDocumentLoader(TragDocumentLoaderParams(file_paths=[])) + return TragKnowledge(auth_params=auth_params, document_loader=document_loader) + + +# Build the knowledge tool: auth_params from your config (e.g. create_trag_auth_params_xxx()) +auth_params = get_auth_params() # from .config or TragAuthParams(...) +knowledge = _create_trag_knowledge(auth_params=auth_params) +my_knowledge_tool = LangchainKnowledgeSearchTool( + rag=knowledge, + top_k=5, + min_score=0.0, + knowledge_filter=None, # optional static filter +) + +graph.add_knowledge_node( + name="search_kb", + query="user_query", # or callable: lambda state: state.get("query", ""), + tool=my_knowledge_tool, + config=NodeConfig(name="search_kb", description="Knowledge search"), +) +``` + +Common options: +- query: Retrieval query, can be a string or a callable `(state) -> str` +- tool: Pre-built `LangchainKnowledgeSearchTool` instance, must not be None +- config / callbacks: Same as add_node + +### add_mcp_node + +Scenario: Invoke a specific MCP tool within the graph; parameters are sourced from the upstream node's output written to `state[STATE_KEY_NODE_RESPONSES][req_src_node]`. + +Note: An upstream node must first write MCP request parameters to `node_responses[req_src_node]`; execution failures will raise an error directly, facilitating troubleshooting of invalid requests. + +```python +graph.add_mcp_node( + name="call_mcp", + mcp_toolset=my_mcp_toolset, + selected_tool_name="my_mcp_tool", + req_src_node="build_request", + config=NodeConfig(name="call_mcp", description="Invoke MCP tool"), +) +``` + +Common options: +- mcp_toolset: A configured `MCPToolset` instance +- selected_tool_name: The name of the MCP tool to invoke (exact match) +- req_src_node: The upstream node ID that provides request parameters (its output is in `node_responses[req_src_node]`) +- config / callbacks: Same as add_node + +### add_agent_node + +Scenario: Integrate any BaseAgent (e.g., LlmAgent/GraphAgent) as a node in the graph. + +```python +graph.add_agent_node( + node_id="delegate", + agent=delegate_agent, + isolated_messages=True, + input_from_last_response=False, + event_scope="delegate_scope", + input_mapper=StateMapper.rename({"query_text": STATE_KEY_USER_INPUT}), + output_mapper=StateMapper.merge_response("delegate_reply"), + config=NodeConfig(name="delegate", description="Delegate to child agent"), +) +``` + +Common options: +- isolated_messages: Whether to isolate the parent session's message history +- input_from_last_response: Whether to map the parent state's last_response as the child node's user_input +- event_scope: Event branch prefix for the child Agent +- input_mapper / output_mapper: Parent-child state mapping (explicit configuration recommended) +- config / callbacks: Same as add_node + +GraphAgent does not require (nor support) registering Agent nodes via sub_agents; composition relationships are handled uniformly through add_agent_node. + +## Advanced Usage + +This section covers advanced topics including edges and routing, state and Reducers, compilation and execution, node signatures, StateMapper, callbacks, state key constants, and Interrupt. + +### Edges and Routing + +Scenario: Use add_edge for static paths and add_conditional_edges for conditional branching; entry/exit points can be set using set_entry_point / set_finish_point as shorthands. If **input preprocessing** is needed before routing (e.g., a prepare_input node for validation or normalizing user_input), insert an add_node after the entry point and connect it to the conditional edges. + +```python +graph.add_edge(start="extract", end="decide") + +graph.add_conditional_edges( + source="decide", + path=route_choice, + path_map={ + "preview": "preview", + "summarize": "summarize", + }, +) + +graph.set_entry_point("extract") +graph.set_finish_point("format_output") +``` + +- add_edge(start, end): A directed edge from start to end +- add_conditional_edges(source, path, path_map): path(state) returns the next hop key, path_map maps keys to node names +- set_entry_point(key): Equivalent to add_edge(START, key) +- set_finish_point(key): Equivalent to add_edge(key, END) + +### State and Reducer Usage + +The Graph state is a TypedDict (State) passed between nodes. Recommended practices: + +- Inherit from the framework's State to define business fields +- For fields that are "cumulatively updated by multiple nodes", use Annotated[..., reducer] +- Nodes return dict deltas, and the framework merges them according to reducer rules + +**Defining State:** + +```python +from typing import Any + +from google.genai.types import Content +from typing_extensions import Annotated + +from trpc_agent_sdk.dsl.graph import ( + State, + append_list, + merge_dict, + messages_reducer, +) + + +class ArticleState(State): + article: str + summary: str + + execution_log: Annotated[list[dict[str, Any]], append_list] + node_outputs: Annotated[dict[str, Any], merge_dict] + messages: Annotated[list[Content], messages_reducer] +``` + +**Reducer behaviors:** append_list (append to list), merge_dict (shallow merge dictionary), messages_reducer (append to message list). Fields like route and summary that are written by a single node do not need Annotated. + +### Compilation and Execution + +After the graph is built, call `compile`; checkpoint persistence can be optionally configured. + +```python +compiled = graph.compile( + memory_saver_option=MemorySaverOption( + auto_persist=False, + persist_writes=False, + ), +) +agent = GraphAgent( + name="my_workflow", + description="My graph workflow", + graph=compiled, +) +``` + +Notes: +- After compilation, a `CompiledStateGraph` is obtained. It should be passed to GraphAgent's `graph` parameter and executed via Runner (see "Quick Start" above), rather than calling `compiled.astream(...)` directly. +- To access the compilation result: use `compiled.source` to retrieve the original StateGraph, and `compiled.get_node_config(name)` to look up configuration by node name. +- When constructing the graph, you can attach unified before/after/error callbacks (for logging, inspection, metrics) to the entire graph via `StateGraph(MyState, callbacks=global_callbacks)`. +- During Runner execution, the primary path for state persistence is Event.actions.state_delta → SessionService. When using InMemorySessionService, state is not retained after process restart; when using persistent SessionService implementations like Redis/SQL, distributed deployment is supported. + +### Node Signatures and Dependency Injection + +Node definitions must be async methods, i.e., `async def`. + +The Graph automatically injects capabilities based on the method signature. Common signatures are as follows: + +```python +async def node(state: State) -> dict: ... +async def node(state: State, writer: EventWriter) -> dict: ... +async def node(state: State, async_writer: AsyncEventWriter) -> dict: ... +async def node(state: State, ctx: InvocationContext) -> dict: ... +async def node(state: State, writer: EventWriter, ctx: InvocationContext) -> dict: ... +async def node(state: State, async_writer: AsyncEventWriter, ctx: InvocationContext) -> dict: ... +async def node(state: State, writer: EventWriter, async_writer: AsyncEventWriter, ctx: InvocationContext) -> dict: ... +async def node(state: State, callback_ctx, callbacks) -> dict: ... +``` + +Notes: +- writer: Writes events synchronously +- async_writer: Used when await-based control over event flushing is needed +- ctx: Reads session / user_id / session_id, etc. +- callback_ctx / callbacks: Fine-grained callbacks within a node (advanced usage) + +### StateMapper (Input/Output Mapping for add_agent_node) + +StateMapper is used to explicitly control the data flow of agent_nodes, consisting of two steps: + +- input_mapper: Graph State -> Agent Node input State +- output_mapper: Agent Node result -> Graph State update + +The following example illustrates this: + +- The Graph state before invoking the Agent Node contains: {"query_text": "What is RAG?", "route": "llm_agent"} +- The Agent Node only recognizes STATE_KEY_USER_INPUT (i.e., user_input) +- After the Agent Node completes execution, we want to write its final response back to the Graph's query_reply + +```python +from trpc_agent_sdk.dsl.graph import StateMapper, STATE_KEY_USER_INPUT + +graph.add_agent_node( + node_id="query_orchestrator", + agent=orchestrator_agent, + input_mapper=StateMapper.rename({"query_text": STATE_KEY_USER_INPUT}), + output_mapper=StateMapper.merge_response("query_reply"), +) +``` + +The effect of the above configuration is: + +- Before invoking the Agent Node: Renames the Graph's query_text to the Agent Node's user_input +- After the Agent Node finishes: Writes the Agent Node's last_response to the Graph's query_reply + +For more fine-grained control, the following mappers can be combined: + +- pick: Only pass whitelisted fields to the child node. For example, `StateMapper.pick("query", "context")` only passes query and context from the parent state to the child Agent, discarding all other fields. +- exclude: Exclude specified fields, passing all others to the child node. For example, `StateMapper.exclude("api_key", "password")` passes the entire parent state except api_key and password to the child Agent. +- rename: Maps parent state key names to child node key names in the format `{parent_key: child_key}`. For example, `StateMapper.rename({"query_text": STATE_KEY_USER_INPUT, "docs": "context"})` maps the parent state's query_text to the child node's user_input, and docs to context. +- combine: Combines multiple input mappers, with results merged as a union; for duplicate keys, the latter overrides the former. For example, `StateMapper.combine(StateMapper.pick("context"), StateMapper.rename({"query_text": STATE_KEY_USER_INPUT}))` first takes the context field, then renames query_text to user_input, and merges both to pass to the child node. +- merge_response: Writes the child node's last_response to a specified field in the parent state; used only for output_mapper. For example, `StateMapper.merge_response("search_results")` writes the child Agent's response to the parent state's search_results. +- identity: Passes the parent state through as-is, without any trimming or renaming. For example, `StateMapper.identity()` passes the full parent state to the child Agent. +- filter_keys: Filters the parent state's keys by a predicate function. For example, `StateMapper.filter_keys(lambda k: k.startswith("user_"))` only passes fields starting with `user_` to the child node. + +### NodeCallbacks + +NodeCallbacks allows injecting logging, inspection, or other instrumentation before and after node execution, with two levels of granularity: + +- Graph-level: StateGraph(..., callbacks=global_callbacks), applies to all nodes in the graph +- Node-level: add_node(..., callbacks=node_callbacks), applies only to the current node + +```python +from trpc_agent_sdk.dsl.graph import NodeCallbacks, StateGraph + + +global_callbacks = NodeCallbacks() +node_callbacks = NodeCallbacks() + + +async def before_node(ctx, state): + print(f"[before] step={ctx.step_number} node={ctx.node_id}") + return None + + +async def after_node(ctx, state, result, error): + print(f"[after ] step={ctx.step_number} node={ctx.node_id}") + return None + + +global_callbacks.register_before_node(before_node) +node_callbacks.register_after_node(after_node) + +graph = StateGraph(MyState, callbacks=global_callbacks) +graph.add_node(name="extract", action=extract_node, callbacks=node_callbacks) +``` + +Common callback types: + +- register_before_node +- register_after_node +- register_on_error +- register_agent_event + +Callback merge rules: + +- before_node / on_error: Graph-level executes first, then node-level +- after_node: Node-level executes first, then graph-level (so that node-level can modify the output first) + +### State Key Constants and Access Patterns + +The Graph module provides a set of built-in state key constants. It is recommended to use these constants consistently for reading and writing state, avoiding hardcoded strings scattered throughout business code. + +### Constant Key Reference Table + +| Constant | Actual Key | Description | +| --- | --- | --- | +| `STATE_KEY_USER_INPUT` | `user_input` | Current turn's user input | +| `STATE_KEY_MESSAGES` | `messages` | Session message list | +| `STATE_KEY_LAST_RESPONSE` | `last_response` | Most recent response text | +| `STATE_KEY_LAST_RESPONSE_ID` | `last_response_id` | Most recent response ID | +| `STATE_KEY_LAST_TOOL_RESPONSE` | `last_tool_response` | Most recent tool execution result | +| `STATE_KEY_NODE_RESPONSES` | `node_responses` | Aggregated responses by node | +| `STATE_KEY_NODE_STRUCTURED` | `node_structured` | Aggregated structured results by node | + +### Reading State During Graph Execution + +```python +from trpc_agent_sdk.dsl.graph import ( + State, + STATE_KEY_USER_INPUT, + STATE_KEY_LAST_RESPONSE, + STATE_KEY_NODE_RESPONSES, +) + + +async def inspect_state_node(state: State) -> dict[str, str]: + user_input = state.get(STATE_KEY_USER_INPUT, "") + last_response = state.get(STATE_KEY_LAST_RESPONSE, "") + summarize_text = state.get(STATE_KEY_NODE_RESPONSES, {}).get("summarize", "") + return { + "echo_input": user_input, + "debug_last_response": last_response, + "debug_summarize_text": summarize_text, + } +``` + +### Reading State Outside the Graph + +```python +from trpc_agent_sdk.dsl.graph import STATE_KEY_LAST_RESPONSE, STATE_KEY_NODE_RESPONSES + + +session = await session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, +) + +if session and session.state: + last_response = session.state.get(STATE_KEY_LAST_RESPONSE, "") + node_responses = session.state.get(STATE_KEY_NODE_RESPONSES, {}) +``` + +### Interrupt + +The Graph provides interrupt(...), which can pause execution within a node and wait for an external decision: + +```python +from trpc_agent_sdk.dsl.graph import interrupt + + +async def approval_gate(state: State) -> dict[str, Any]: + decision = interrupt({ + "title": "Approval Required", + "options": ["approved", "rejected"], + }) + status = "approved" + if isinstance(decision, dict): + status = str(decision.get("status", "approved")) + return {"approval_status": status} +``` + +The Runner side will receive a LongRunningEvent, and the client resumes via FunctionResponse: + +```python +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.types import Content, FunctionResponse, Part + +async for event in runner.run_async(...): + if isinstance(event, LongRunningEvent): + resume_payload = {"status": "approved", "note": "Proceed"} + resume_response = FunctionResponse( + id=event.function_response.id, + name=event.function_response.name, + response=resume_payload, + ) + resume_content = Content(role="user", parts=[Part(function_response=resume_response)]) + + async for _ in runner.run_async(..., new_message=resume_content): + pass +``` + +**Complete Example:** +- [examples/graph_with_interrupt](../../../examples/graph_with_interrupt/README.md) - Interrupt + Resume example diff --git a/docs/mkdocs/en/human_in_the_loop.md b/docs/mkdocs/en/human_in_the_loop.md new file mode 100644 index 000000000..7d11cb53c --- /dev/null +++ b/docs/mkdocs/en/human_in_the_loop.md @@ -0,0 +1,435 @@ +# Human-In-The-Loop + +During Agent processing, some scenarios require human involvement for judgment or adjustment to improve task completion accuracy. Examples include: +- Risky operation approval: Commonly used when an Agent generates SQL or Shell scripts, whether to execute them often requires human approval. Taking Agent-generated command lines as an example, if approved, the terminal is launched to execute the command and the execution result is passed back to the Agent; if rejected, it may indicate the generated command is problematic and the Agent needs to regenerate an alternative command. +- Execution plan approval: For complex tasks, the Agent first generates a Plan and submits it to the user for confirmation. If the user agrees, the plan is executed step by step; if the user disagrees, they can provide adjustment prompts to regenerate a Plan that meets their requirements. + +## Implementation Mechanism + +There are currently two approaches in the industry: one is to provide a UserAgent responsible for communicating with users (e.g., autogen/agentscope), orchestrating multiple Agents into an Agent application. This approach is simple and easy to use, but introducing an additional Agent increases application complexity and reduces the flexibility of user interaction (since the user interaction interface is fixed), making it unable to cover all scenarios. The other approach uses Tools as the integration point for human involvement (e.g., langgraph/agno), treating human operations as part of the Tool process and using human-generated results as Tool call results. This approach is highly flexible but introduces implementation complexity. + +The framework currently supports the Tool-as-integration-point approach, providing `LongRunningFunctionTool` and `LongRunningEvent` on `LlmAgent` to implement this mechanism. As shown in the diagram below, specifically, after a user creates a tool using `LongRunningFunctionTool`, the parameters passed when the Agent calls this tool can be treated as operations generated by the Agent that require human confirmation. Users can organize these operations into a dict result as the Tool's return value in the Tool's implementation. After `LongRunningFunctionTool` is executed, the framework will generate a `LongRunningEvent` event. When the user identifies this event, they can perform the corresponding human operation and then submit the result to the Agent to continue execution. + + + + +### LongRunningEvent +`LongRunningEvent` is a special event type indicating that Agent execution has been paused, awaiting human involvement. +- `function_call`: The Tool call that triggered the operation; +- `function_response`: The initial response from the Tool (usually contains pending status information). Based on this object (primarily `id` and `name`), the result of the human operation can be submitted to the Agent. See the code example below; + +## LlmAgent Usage + +### 1. Create LongRunningFunctionTool + +First, define a Tool that requires human approval: + +```python +async def human_approval_required(task_description: str, details: dict) -> dict: + """A long-running function that requires human approval. + + Args: + task_description: Description of the task requiring approval + details: Additional details about the task + + Returns: + A dictionary indicating the task is pending human approval + """ + return { + "status": "pending_approval", + "message": f"Task '{task_description}' requires human approval", + "details": details, + "approval_id": str(uuid.uuid4()), + "timestamp": time.time(), + } + + +from trpc_agent_sdk.tools import LongRunningFunctionTool +approval_tool = LongRunningFunctionTool(human_approval_required) +``` + +### 2. Configure the Agent + +Wrap the tool with `LongRunningFunctionTool` and configure the Agent: + +```python +import os +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import LongRunningFunctionTool + +def create_agent(): + model = OpenAIModel( + model_name=os.getenv("TRPC_AGENT_MODEL_NAME", ""), + api_key=os.getenv("TRPC_AGENT_API_KEY", ""), + base_url=os.getenv("TRPC_AGENT_BASE_URL", ""), + ) + approval_tool = LongRunningFunctionTool(human_approval_required) + + agent = LlmAgent( + name="human_in_loop_agent", + description="Agent demonstrating long-running tools with human-in-the-loop", + model=model, + instruction="""You are an assistant that can handle long-running operations requiring human approval. +When you encounter tasks that need approval, use the appropriate tool and wait for human intervention.""", + tools=[approval_tool], + ) + return agent +``` + +### 3. Capture LongRunningEvent + +```python +@dataclass +class InvocationParams: + """Parameters for running an invocation""" + user_id: str + session_id: str + agent: LlmAgent + session_service: InMemorySessionService + app_name: str + +async def run_invocation( + params: InvocationParams, + content: Content, +) -> Optional[LongRunningEvent]: + """Run an invocation with a fresh runner instance.""" + runner = Runner( + app_name=params.app_name, + agent=params.agent, + session_service=params.session_service, + ) + + captured_long_running_event = None + + try: + async for event in runner.run_async( + user_id=params.user_id, + session_id=params.session_id, + new_message=content, + ): + if isinstance(event, LongRunningEvent): + # Capture long-running event + captured_long_running_event = event + print(f"\n🔄 [Long-running operation detected]") + print(f" Function: {event.function_call.name}") + print(f" Response: {event.function_response.response}") + elif event.content and event.content.parts and event.author != "user": + # Handle other events... + pass + finally: + await runner.close() + + return captured_long_running_event +``` + +### 4. Perform Human Operations + +Note that only the `id`, `name`, and `response` from the `FunctionResponse` are needed to create the Content for resuming Agent execution. In scenarios where the Agent is served as a service, you only need to return this information to the frontend, and the frontend can include this information in the next Agent call after the human operation is completed. + +```python +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.events import LongRunningEvent + +async def run_agent(): + """Run the agent with support for long-running events""" + + # Create Agent and Session Service + agent = create_agent() + session_service = InMemorySessionService() + + params = InvocationParams( + user_id="demo_user", + session_id=str(uuid.uuid4()), + agent=agent, + session_service=session_service, + app_name="agent_demo", + ) + + # Query that triggers a long-running operation + query = "I need approval to delete the production database. The details are: environment=prod, database=user_data, reason=migration" + user_content = Content(parts=[Part.from_text(text=query)]) + + # First run - trigger human approval + long_running_event = await run_invocation(params, user_content) + + # Simulate human intervention + if long_running_event: + print("\n👤 Human intervention simulation...") + await asyncio.sleep(2) # Simulate human thinking time + + # Get the initial response returned by the Tool + response_data = long_running_event.function_response.response + if response_data["status"] != "pending_approval": + print(" ❌ Invalid response status") + return + + # Simulate human providing approval input + response_data["status"] = "approved" + response_data["message"] = "APPROVED: The database deletion is approved for migration purposes." + response_data["approved_by"] = "admin" + response_data["timestamp"] = time.time() + # You can also create a new tool return result instead of reusing function_response.response + # response_data = {"user_is_approved": True} + + # Create the message for resuming Agent execution. In a service scenario, + # you only need to return function_response's id and name to the frontend, and include this information in the next call to create resume_content + resume_function_response = FunctionResponse( + id=long_running_event.function_response.id, + name=long_running_event.function_response.name, + response=response_data, + ) + resume_content = Content(role="user", parts=[Part(function_response=resume_function_response)]) + + # Resume Agent execution + await run_invocation(params, resume_content) +``` + +## LangGraphAgent Usage + +LangGraphAgent adapts LangGraph's interrupt mechanism to work with the framework's LongRunningEvent interaction mechanism. Unlike LlmAgent, it can resume the original conversation within a Node, whereas LlmAgent's Tool cannot continue execution internally. + +To use LangGraphAgent's interrupt capability, make sure to enable `checkpoint`, as this capability pauses graph execution and requires storing the graph's state information to resume execution. + +Note that when LangGraph resumes execution, it re-executes from the beginning of the Node to the interrupt point. This means the logic in that segment will be executed twice; please optimize accordingly if there are time-consuming operations. + +### 1. Build a Graph with Tool Output Approval Confirmation + +**Note: checkpoint must be enabled** + +```python +import os +from typing import Annotated, Literal, TypedDict + +from langchain.chat_models import init_chat_model +from langchain_core.tools import tool +from langgraph.graph import StateGraph, START, END +from langgraph.graph.message import add_messages +from langgraph.prebuilt import tools_condition, ToolNode +from langgraph.types import interrupt, Command +from langgraph.checkpoint.memory import InMemorySaver + +from trpc_agent_sdk.agents import langgraph_llm_node, langgraph_tool_node + + +@tool +@langgraph_tool_node +def execute_database_operation(operation: str, database: str, details: dict) -> str: + """Execute a database operation that requires approval. + + Args: + operation: The type of operation ('delete', 'update', 'create') + database: The database name + details: Additional operation details + """ + return f"Database operation '{operation}' on '{database}' executed successfully with details: {details}" + + +class State(TypedDict): + messages: Annotated[list, add_messages] + task_description: str + approval_status: str + + +def build_graph(): + """Build a LangGraph with human-in-the-loop approval using interrupt.""" + + model = init_chat_model( + os.getenv("TRPC_AGENT_MODEL_NAME", ""), + api_key=os.getenv("TRPC_AGENT_API_KEY", ""), + api_base=os.getenv("TRPC_AGENT_BASE_URL", ""), + ) + tools = [execute_database_operation] + llm_with_tools = model.bind_tools(tools) + + @langgraph_llm_node + def chatbot(state: State): + """Chatbot node that can use tools""" + return {"messages": [llm_with_tools.invoke(state["messages"])]} + + # Human approval node using LangGraph interrupt + def human_approval(state: State) -> Command[Literal["approved_path", "rejected_path"]]: + """Human approval node that interrupts execution for human input.""" + task_info = { + "_node_name": "human_approval", + "question": "Do you approve this database operation?", + } + + # Interrupt execution and wait for human input + decision = interrupt(task_info) + approval_status = decision.get("status", "rejected") + + if approval_status in ["approved", "approve", "yes", "true"]: + return Command(goto="approved_path", update={"approval_status": "approved"}) + else: + return Command(goto="rejected_path", update={"approval_status": "rejected"}) + + # Approved and rejected path nodes + def approved_node(state: State) -> State: + """Handle approved operations""" + return {"messages": [{"role": "assistant", "content": "Operation has been approved and will be executed."}]} + + def rejected_node(state: State) -> State: + """Handle rejected operations""" + return {"messages": [{"role": "assistant", "content": "Operation has been rejected and cancelled."}]} + + # Build the graph + graph_builder = StateGraph(State) + graph_builder.add_node("chatbot", chatbot) + graph_builder.add_node("human_approval", human_approval) + graph_builder.add_node("approved_path", approved_node) + graph_builder.add_node("rejected_path", rejected_node) + + tool_node = ToolNode(tools=tools) + graph_builder.add_node("tools", tool_node) + + graph_builder.add_edge(START, "chatbot") + graph_builder.add_conditional_edges("chatbot", tools_condition) + graph_builder.add_edge("tools", "human_approval") + graph_builder.add_edge("approved_path", END) + graph_builder.add_edge("rejected_path", END) + + # MUST Use checkpointer for interrupt support + checkpointer = InMemorySaver() + return graph_builder.compile(checkpointer=checkpointer) +``` + +### 2. Create LangGraphAgent + +```python +from trpc_agent_sdk.agents import LangGraphAgent + +def create_agent(): + """Create a LangGraph Agent with human-in-the-loop support""" + graph = build_graph() + + return LangGraphAgent( + name="human_in_loop_langgraph_agent", + description="A LangGraph agent that requires human approval for database operations", + graph=graph, + instruction="""You are a database management assistant that requires human approval for all operations. + +When a user requests a database operation: +1. Use the execute_database_operation tool to prepare the operation +2. The system will automatically request human approval +3. Only proceed if the human approves the operation + +Always be clear about what operation you're about to perform and why it needs approval.""", + ) +``` + +### 3. Capture and Handle LongRunningEvent + +The Human-In-The-Loop handling for LangGraphAgent is the same as for LlmAgent, both achieved by capturing the `LongRunningEvent` event: + +```python +from dataclasses import dataclass +from typing import Optional + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.agents import LangGraphAgent +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.types import Content, Part, FunctionResponse + + +@dataclass +class InvocationParams: + """Parameters for running an invocation""" + user_id: str + session_id: str + agent: LangGraphAgent + session_service: InMemorySessionService + app_name: str + + +async def run_invocation( + params: InvocationParams, + content: Content, +) -> Optional[LongRunningEvent]: + """Run an invocation with a fresh runner instance.""" + runner = Runner( + app_name=params.app_name, + agent=params.agent, + session_service=params.session_service, + ) + + captured_long_running_event = None + + try: + async for event in runner.run_async( + user_id=params.user_id, + session_id=params.session_id, + new_message=content, + ): + if isinstance(event, LongRunningEvent): + # Capture long-running event + captured_long_running_event = event + print(f"\n🔄 [Long-running operation detected]") + print(f" Function: {event.function_call.name}") + print(f" Response: {event.function_response.response}") + elif event.content and event.content.parts and event.author != "user": + # Handle other events... + pass + finally: + await runner.close() + + return captured_long_running_event +``` + +### 4. Perform Human Operations + +The human intervention handling is identical to LlmAgent: + +```python +import asyncio +import uuid + +async def run_human_in_loop_agent(): + """Run the agent with support for long-running events""" + + # Create Agent and Session Service + agent = create_agent() + session_service = InMemorySessionService() + + params = InvocationParams( + user_id="demo_user", + session_id=str(uuid.uuid4()), + agent=agent, + session_service=session_service, + app_name="langgraph_human_in_loop_demo", + ) + + # Query that triggers a long-running operation + query = "I need to delete the production database 'user_data' for migration purposes. The details are: environment=prod, backup_created=true, reason=migration_to_new_system" + user_content = Content(parts=[Part.from_text(text=query)]) + + # First run - trigger human approval + long_running_event = await run_invocation(params, user_content) + + # Simulate human intervention + if long_running_event: + print("\n👤 Human intervention simulation...") + await asyncio.sleep(2) # Simulate human thinking time + + # Simulate human decision + human_decision = "approved" # or "rejected" + resume_data = {"status": human_decision} + + # Create the message for resuming Agent execution + resume_function_response = FunctionResponse( + id=long_running_event.function_response.id, + name=long_running_event.function_response.name, + response=resume_data, + ) + resume_content = Content(role="user", parts=[Part(function_response=resume_function_response)]) + + # Resume Agent execution + await run_invocation(params, resume_content) +``` + +## Complete Code Examples + +For complete example code, please refer to: +- LlmAgent: [examples/llmagent_with_human_in_the_loop/README.md](../../../examples/llmagent_with_human_in_the_loop/README.md) +- LangGraphAgent: [examples/langgraphagent_with_human_in_the_loop/README.md](../../../examples/langgraphagent_with_human_in_the_loop/README.md) diff --git a/docs/mkdocs/en/knowledge.md b/docs/mkdocs/en/knowledge.md new file mode 100644 index 000000000..dbd11b11f --- /dev/null +++ b/docs/mkdocs/en/knowledge.md @@ -0,0 +1,679 @@ +# LangchainKnowledge Documentation + +## Overview + +`LangchainKnowledge` is the knowledge management system in the tRPC-Agent framework. It supports the LangChain ecosystem and provides Retrieval-Augmented Generation (RAG) capabilities for Agents. Users only need to declare RAG component types (such as embedding models and vector stores) to build a basic RAG pipeline. + +### Usage Pattern + +The Knowledge system follows this usage pattern: + +1. **Create Knowledge**: Select and configure RAG components (vector store, embedder, document loader, etc.). +2. **Load Documents**: Call `create_vectorstore_from_document` to build a vector store from document sources. +3. **Integrate with Agent**: Add the search tool to the Agent's `tools` list. +4. **Agent Invocation**: The Agent automatically invokes the knowledge search tool to retrieve context during conversations. + +This pattern provides: + +- **Semantic Retrieval**: Supports similarity search, similarity search with score threshold (similarity_score_threshold), and Maximum Marginal Relevance (MMR) +- **LangChain Ecosystem Compatibility**: Seamless integration with LangChain's VectorStore, Embeddings, Retriever, and other components +- **Reranking Capability**: Supports reranking retrieved results from the vector database via a Retriever +- **Metadata Filtering**: Supports static filtering and Agent-driven dynamic filtering through `KnowledgeFilterExpr` +- **Extensible Architecture**: Based on the `KnowledgeBase` abstract base class, supports custom knowledge backends + +### Agent Integration Methods + +The Knowledge system supports two methods of integration with Agents: + +- **Search Tool Integration (Recommended)**: Use `LangchainKnowledgeSearchTool` and pass it directly to the Agent's `tools` parameter. +- **Agentic Filtered Search**: Use `AgenticLangchainKnowledgeSearchTool` for dynamic filtering, where the Agent can automatically construct filter conditions from user queries. + +## Installation + +- **Version Compatibility**: This module supports LangChain 0.3.x and 1.x.x versions, using try/except for compatibility handling within the module. For more information, refer to [LangChain Version Compatibility](#langchain-version-compatibility) + +### Dependency Requirements + +Configure in `pyproject.toml`: + +```toml +dependencies = [ + "langchain>=0.3.0", + "langchain-core>=0.3.0", + "langchain-text-splitters>=0.3.0", +] +``` + +## Use Cases + +`LangchainKnowledge` supports four usage modes: +- Full LangChain chain: Supports seamless migration from LangChain by directly using a full chain in the `trpc_agent` framework. + +- Vector store retrieval: Builds a vector store from documents and retrieves query-relevant documents using relevance-based methods. + +- Retriever-based retrieval: Builds a retriever from documents and retrieves query-relevant documents using relevance-based methods. + +- Vector store retrieval with retriever reranking: Builds a vector store, retrieves documents for the query, and reranks results with a retriever. + +## Creating LangchainKnowledge + +### Component Initialization + +- `chain`: A user-defined full Langchain chain implementing the RAG pipeline. If non-empty, other components are ignored and the full chain is executed; if empty, the chain component is ignored + +- `prompt_template`: A prompt template for embedding the query into a template. If empty, the raw query is passed through + +- `document_loader`: A document loader for asynchronous document loading. If non-empty, a file path must be specified; if empty, documents must be specified during vector store or retriever initialization + +- `document_transformer`: A document transformer for asynchronous document chunking; if empty, no chunking is performed + +- `embedder`: An embedding model for converting documents to corresponding vectors; can be empty if the vector store itself provides embedding capability. + +- `vectorstore`: A vector store for building a database from documents and supporting document retrieval. The vector store and retriever cannot both be empty, otherwise retrieval is not possible. + +- `retriever`: A retriever for building a database from documents and supporting document retrieval; it can also support reranking, e.g., BM25Retriever. The vector store and retriever cannot both be empty, otherwise retrieval is not possible. When both `vectorstore` and `retriever` are used simultaneously, the `retriever` is used to rerank the retrieval results from the `vectorstore`, which requires the `retriever` to have a `from_documents` interface. + +For detailed usage of each component, see: [Document Loader](./knowledge_document_loader.md), [Text Splitter](./knowledge_text_splitter.md), [Embedder](./knowledge_embedder.md), [VectorStore](./knowledge_vectorstore.md), [Retrievers](./knowledge_retrievers.md), [Prompt Template](./knowledge_prompt_template.md), [Custom Components](./knowledge_custom_components.md) + +### Core Method Details + +The `search` method is the core method of `LangchainKnowledge`. It performs: +1. Retrieving conversation context, which can be injected into the query to enhance it +2. Retrieving relevant documents based on the declared RAG component types +3. Converting retrieved documents into data types supported by the `trpc_agent` framework + +The `create_vectorstore_from_document` method provides the capability to create a vector database from documents, including: +Document loading -> document chunking (optional) -> vectorization -> storage into the vector store + +### Search Type Description + +`SearchType` defines three search methods that can be specified when creating a search tool: + +| Search Type | Enum Value | Description | +|-------------|------------|-------------| +| `SIMILARITY` | `"similarity"` | Pure similarity search, returns the top K most similar documents | +| `SIMILARITY_SCORE_THRESHOLD` | `"similarity_score_threshold"` | Similarity search with relevance scores, results include scores | +| `MAX_MARGINAL_RELEVANCE` | `"mmr"` | Maximum Marginal Relevance, balances between relevance and diversity | + +## Core Component Overview + +### Module Structure + +The Knowledge system uses a layered architecture, and both core interfaces and implementations are located in `trpc_agent_sdk`: + +``` +trpc_agent_sdk/knowledge/ # Core interface layer +├── _knowledge.py # KnowledgeBase abstract base class, SearchRequest/SearchResult data models +└── _filter_expr.py # KnowledgeFilterExpr unified filter expression + +trpc_agent_sdk/server/knowledge/ # Implementation layer +├── langchain_knowledge.py # LangchainKnowledge — RAG implementation based on LangChain ecosystem +└── tools/ + └── langchain_knowledge_searchtool.py # LangchainKnowledgeSearchTool / AgenticLangchainKnowledgeSearchTool +``` + +### KnowledgeBase Interface + +`KnowledgeBase` is the abstract base class for all knowledge backends, defining a unified search interface: + +```python +from trpc_agent_sdk.knowledge import KnowledgeBase, SearchRequest, SearchResult + +class KnowledgeBase(ABC): + async def search(self, ctx: AgentContext, req: SearchRequest) -> SearchResult: + """Perform semantic search and return the best results. This is the primary method for Agent RAG.""" + + def build_search_extra_params(self, filter_expr: KnowledgeFilterExpr | None) -> dict: + """Convert the unified filter expression to backend-specific parameters.""" +``` + +The framework provides two built-in implementations: +- **LangchainKnowledge**: Integrates with the LangChain ecosystem, supporting LangChain's full suite of components including VectorStore, Retriever, Embeddings, etc. + +### Search Data Structures Overview + +#### SearchParams — Search Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `search_type` | `str` | `"similarity"` | Search method: `similarity`, `similarity_score_threshold`, `mmr` | +| `top_p` | `float` | `0.8` | Cumulative probability threshold | +| `rank_top_k` | `int` | `3` | Return the top K most relevant results | +| `rerank_threshold` | `float` | `0.3` | Minimum relevance score threshold for reranking | +| `default_score` | `float` | `0.0` | Default relevance score | +| `generator_temperature` | `float` | `0.0` | Generation model temperature parameter | +| `generator_max_tokens` | `int` | `5000` | Maximum output tokens for the generation model | +| `extra_params` | `dict` | `{}` | Backend-specific extension parameters | + +#### SearchRequest — Search Request + +| Field | Type | Description | +|-------|------|-------------| +| `query` | `Part` | Search query content | +| `history` | `List[BaseMessage]` | Recent conversation messages, used as context | +| `user_id` | `str` | User ID, can be used for personalized search | +| `session_id` | `str` | Session ID, can be used for session-specific context | +| `params` | `SearchParams` | Search parameter configuration | + +#### SearchResult — Search Result + +`SearchResult` contains a `documents` list, where each `SearchDocument` includes: +- `document`: The matched document (LangChain `Document` object, containing `page_content` and `metadata`) +- `score`: Relevance score + +## Filters + +`KnowledgeFilterExpr` provides a unified filter expression model that supports precise filtering of search results based on document metadata. It can be used for static configuration or dynamically generated by the Agent. + +### Supported Operators + +| Category | Operator | Description | +|----------|----------|-------------| +| Comparison Operators | `eq`, `ne` | Equal to, Not equal to | +| Comparison Operators | `gt`, `gte`, `lt`, `lte` | Greater than, Greater than or equal to, Less than, Less than or equal to | +| Set Operators | `in`, `not in` | In set, Not in set | +| Fuzzy Operators | `like`, `not like` | Fuzzy match | +| Range Operators | `between` | Range interval (value is a two-element list) | +| Logical Operators | `and`, `or` | Logical AND, Logical OR (value is a list of sub-conditions, supports nesting) | + +### Filter Examples + +```python +from trpc_agent_sdk.knowledge import KnowledgeFilterExpr + +# Simple condition: category equals "machine-learning" +simple_filter = KnowledgeFilterExpr.model_validate({ + "field": "metadata.category", + "operator": "eq", + "value": "machine-learning", +}) + +# Compound condition: status is active AND year >= 2024 +compound_filter = KnowledgeFilterExpr.model_validate({ + "operator": "and", + "value": [ + {"field": "metadata.status", "operator": "eq", "value": "active"}, + {"field": "metadata.year", "operator": "gte", "value": 2024}, + ], +}) + +# Nested condition: (category is AI OR ML) AND status is published +nested_filter = KnowledgeFilterExpr.model_validate({ + "operator": "and", + "value": [ + { + "operator": "or", + "value": [ + {"field": "metadata.category", "operator": "eq", "value": "AI"}, + {"field": "metadata.category", "operator": "eq", "value": "ML"}, + ], + }, + {"field": "metadata.status", "operator": "eq", "value": "published"}, + ], +}) +``` + +## Search Tools + +The framework provides two search tools for integrating knowledge base capabilities into Agents. + +### LangchainKnowledgeSearchTool + +A basic knowledge search tool that supports semantic search and static filtering. The Agent automatically invokes this tool to retrieve knowledge during conversations: + +```python +from trpc_agent_sdk.server.knowledge.tools import LangchainKnowledgeSearchTool +from trpc_agent_sdk.server.knowledge.langchain_knowledge import SearchType + +search_tool = LangchainKnowledgeSearchTool( + rag=rag, # LangchainKnowledge instance + top_k=3, # Return top K most relevant results + search_type=SearchType.SIMILARITY, # Search method + min_score=0.5, # Minimum relevance score filter +) +``` + +### AgenticLangchainKnowledgeSearchTool + +An agentic filtered search tool that adds dynamic filtering capabilities on top of the basic search. The Agent can automatically construct `KnowledgeFilterExpr` filter conditions based on user queries for precise metadata-based search. + +Dynamic filtering does not require you to manually construct `dynamic_filter` — simply mount the tool on the Agent. The LLM will automatically decide whether to generate filter conditions at runtime based on the parameter descriptions in the tool declaration: + +**Step 1: Create the tool and mount it on the Agent** + +```python +from trpc_agent_sdk.agents.llm_agent import LlmAgent +from trpc_agent_sdk.knowledge import KnowledgeFilterExpr +from trpc_agent_sdk.server.knowledge.tools import AgenticLangchainKnowledgeSearchTool +from trpc_agent_sdk.server.knowledge.langchain_knowledge import SearchType + +# Optional: static filter condition, always in effect +static_filter = KnowledgeFilterExpr.model_validate({ + "field": "metadata.category", + "operator": "eq", + "value": "machine-learning", +}) + +agentic_search_tool = AgenticLangchainKnowledgeSearchTool( + rag=rag, + top_k=5, + search_type=SearchType.SIMILARITY, + min_score=0.5, + knowledge_filter=static_filter, # Optional; if omitted, filter conditions are entirely determined by the LLM dynamically +) + +agent = LlmAgent( + name="knowledge_agent", + model=model, + instruction="You are a knowledge base assistant. Search relevant documents based on user questions and provide answers.", + tools=[agentic_search_tool], +) +``` + +**Step 2: LLM automatically generates dynamic filters at runtime** + +When a user asks a question, the LLM automatically determines whether `dynamic_filter` is needed based on the query semantics. For example: + +| User Query | LLM-Generated Tool Call | +|---|---| +| "Introduce deep learning" | `{"query": "deep learning"}` — No dynamic filter needed; only static filter applies | +| "Find papers published in 2024" | `{"query": "papers", "dynamic_filter": {"field": "metadata.year", "operator": "eq", "value": 2024}}` — LLM automatically extracts the year to build a filter | +| "Find active English documents" | `{"query": "English documents", "dynamic_filter": {"operator": "and", "value": [{"field": "metadata.status", "operator": "eq", "value": "active"}, {"field": "metadata.language", "operator": "eq", "value": "en"}]}}` — LLM combines multiple conditions | + +**Step 3: Framework automatically merges static and dynamic filters** + +If both `knowledge_filter` (static) and `dynamic_filter` (dynamic, passed by the LLM) are configured, the framework automatically merges them using AND logic. Using the second example above, the effective filter condition is equivalent to: + +```json +{ + "operator": "and", + "value": [ + {"field": "metadata.category", "operator": "eq", "value": "machine-learning"}, + {"field": "metadata.year", "operator": "eq", "value": 2024} + ] +} +``` + +### Search Tool Configuration Options + +Both search tools support the following configuration options: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `rag` | `LangchainKnowledge` | Required | Knowledge instance | +| `top_k` | `int` | `3` | Return the top K most relevant results | +| `search_type` | `SearchType` | `SIMILARITY` | Search method | +| `min_score` | `float` | `0.0` | Minimum relevance score; documents below this score are filtered out | +| `knowledge_filter` | `KnowledgeFilterExpr` | `None` | Static metadata filter condition | +| `filters_name` | `list[str]` | `None` | List of associated Filter names | +| `filters` | `list[BaseFilter]` | `None` | List of associated Filter instances | + + +## Integration with Agent + +### Method 1: Using Search Tools (Recommended) + +Use `LangchainKnowledgeSearchTool` or `AgenticLangchainKnowledgeSearchTool` directly as Agent tools, without the need to manually write search functions: + +```python +import os +import tempfile + +from langchain_community.document_loaders import TextLoader +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.vectorstores import InMemoryVectorStore +from langchain_huggingface import HuggingFaceEmbeddings +try: + from langchain.text_splitter import RecursiveCharacterTextSplitter +except ModuleNotFoundError: + from langchain_text_splitters import RecursiveCharacterTextSplitter + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.server.knowledge.langchain_knowledge import ( + LangchainKnowledge, + SearchType, +) +from trpc_agent_sdk.server.knowledge.tools import LangchainKnowledgeSearchTool + +# Prompt template +INSTRUCTION = "You are a helpful assistant. Be conversational and remember our previous exchanges." + +RAG_PROMPT_TEMPLATE = """Answer the question gently: + Query: {query} + """ +rag_prompt = ChatPromptTemplate.from_template(RAG_PROMPT_TEMPLATE) + +# Model configuration (read from environment variables) +api_key = os.getenv('TRPC_AGENT_API_KEY', '') +base_url = os.getenv('TRPC_AGENT_BASE_URL', '') +model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + +model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=base_url) + +# Build knowledge +def build_knowledge(): + """Build RAG Knowledge""" + # Embedder + embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + # VectorStore + vectorstore = InMemoryVectorStore(embedder) + # Document Loader: write text to a temporary file and then load it + text_content = ("人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支," + "它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。") + tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w", encoding="utf-8") + tmp_file.write(text_content) + tmp_file.flush() + tmp_file.close() + text_loader = TextLoader(tmp_file.name, encoding="utf-8") + # Document Transformer: chunk_size is set to 10 because the test text is short; adjust according to actual text length in practice + text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) + + # Assemble LangchainKnowledge + rag = LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, + ) + return rag + +rag = build_knowledge() + +# Create LangchainKnowledgeSearchTool and pass it to the Agent +search_tool = LangchainKnowledgeSearchTool(rag, top_k=1, search_type=SearchType.SIMILARITY) +# Or use the agentic filtered search tool +# AgenticLangchainKnowledgeSearchTool(rag, top_k=5, min_score=0.5), +root_agent = LlmAgent( + name="rag_agent", + description="A helpful assistant for conversation with RAG knowledge", + model=model, + instruction=INSTRUCTION, + tools=[search_tool], # Use LangchainKnowledgeSearchTool directly, no need to manually wrap search functions +) +``` + +For the complete example, see [examples/knowledge_with_searchtool_rag_agent/run_agent.py](../../../examples/knowledge_with_searchtool_rag_agent/run_agent.py) + +### Method 2: Custom Function Tool + +Wrap the `simple_search` method as a `FunctionTool`, suitable for scenarios that require custom search logic or result processing: + + +```python +import tempfile + +from langchain_community.document_loaders import TextLoader +from langchain_core.vectorstores import InMemoryVectorStore +from langchain_huggingface import HuggingFaceEmbeddings +try: + from langchain.text_splitter import RecursiveCharacterTextSplitter +except ModuleNotFoundError: + from langchain_text_splitters import RecursiveCharacterTextSplitter + +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.knowledge import SearchRequest, SearchResult +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +from .prompts import rag_prompt + + +def build_knowledge(): + """Build the RAG knowledge chain""" + embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + vectorstore = InMemoryVectorStore(embedder) + # Use TextLoader: write text to a temporary file and then load it + text_content = ("人工智能(Artificial Intelligence,简称AI)是计算机科学的一个分支," + "它企图了解智能的实质,并生产出一种新的能以人类智能相似的方式做出反应的智能机器。") + tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w", encoding="utf-8") + tmp_file.write(text_content) + tmp_file.flush() + tmp_file.close() + text_loader = TextLoader(tmp_file.name, encoding="utf-8") + # chunk_size is set to 10 because the test text is short; adjust according to actual text length in practice + text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) + + rag = LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, + ) + return rag + + +rag = build_knowledge() + +# Build the simple_search method +async def simple_search(query: str): + """Search the knowledge base for relevant documents""" + # metadata can be used to store metadata + metadata = { + 'assistant_name': 'test', # Agent Name, can be used for context + 'runnable_config': {}, # Runnable configuration in Langchain + } + ctx = new_agent_context(timeout=3000, metadata=metadata) + sr: SearchRequest = SearchRequest() + sr.query = Part.from_text(text=query) + search_result: SearchResult = await rag.search(ctx, sr) + if len(search_result.documents) == 0: + return {"status": "failed", "report": "No documents found"} + + best_doc = search_result.documents[0].document + return {"status": "success", "report": f"content: {best_doc.page_content}"} +``` + +```python +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 import FunctionTool + +from .prompts import INSTRUCTION +from .tools import simple_search +from .config import get_model_config + + +def _create_model() -> LLMModel: + """ Create a model""" + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + return model + + +def create_agent() -> LlmAgent: + """ Create an agent""" + agent = LlmAgent( + name="rag_agent", + description="A helpful assistant for conversation, ", + model=_create_model(), + instruction=INSTRUCTION, + tools=[FunctionTool(simple_search)], # Wrap simple_search as a FunctionTool + ) + return agent + + +root_agent = create_agent() +``` + +For the complete example, see: [examples/knowledge_with_rag_agent/run_agent.py](../../../examples/knowledge_with_rag_agent/run_agent.py) + +## Custom Knowledge Backend + +By inheriting the `KnowledgeBase` abstract base class, you can implement a custom knowledge backend. Simply implement the `search` method to seamlessly integrate with the framework's search tools: + +```python +from trpc_agent_sdk.knowledge import KnowledgeBase, SearchRequest, SearchResult +from trpc_agent_sdk.context import AgentContext + +class MyKnowledge(KnowledgeBase): + async def search(self, ctx: AgentContext, req: SearchRequest) -> SearchResult: + # Custom retrieval logic + ... +``` + +The built-in `TragKnowledge` is an example of a custom backend. It inherits from `LangchainKnowledge` and integrates with the TRAG vector retrieval service, replacing the vector database retrieval logic while reusing capabilities such as prompt construction and retriever reranking. + +## LangChain Version Compatibility + +### Major Changes + +#### 1. Text Splitters Import Path Changes + +**LangChain 0.3.x:** + +```python +from langchain.text_splitter import RecursiveCharacterTextSplitter +``` + +**LangChain 1.x.x:** + +```python +from langchain_text_splitters import RecursiveCharacterTextSplitter +``` + +**Compatible approach:** + +```python +try: + # Import for langchain v1.x.x + from langchain_text_splitters import RecursiveCharacterTextSplitter +except ImportError: + # Import for langchain v0.3.x + from langchain.text_splitter import RecursiveCharacterTextSplitter +``` + +#### 2. Chain vs Runnable + +**LangChain 0.3.x:** + +```python +from langchain.chains.base import Chain +``` + +**LangChain 1.x.x:** + +```python +from langchain_core.runnables import Runnable +# Chain is deprecated; Runnable is recommended +``` + +**Compatibility handling:** + +This compatibility is already handled in `langchain_knowledge.py`: + +```python +try: + from langchain_core.runnables import Runnable as Chain +except ImportError: + from langchain.chains.base import Chain +``` + +### Example Code Compatibility + +All example code has been updated to be compatible with both versions. For example: + +```python +# Compatible imports for LangChain 0.3.x and 1.x.x +try: + # Import for langchain v1.x.x + from langchain_text_splitters import RecursiveCharacterTextSplitter +except ImportError: + # Import for langchain v0.3.x + from langchain.text_splitter import RecursiveCharacterTextSplitter + +from langchain_community.document_loaders import TextLoader +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.vectorstores import InMemoryVectorStore +from langchain_huggingface import HuggingFaceEmbeddings +``` + +### Upgrade Guide + +#### Upgrading from LangChain 0.3.x to 1.x.x + +1. **Update dependencies:** + +```bash +pip install --upgrade langchain langchain-core langchain-text-splitters +``` + +2. **No code changes required:** + - All example code is already compatible with both versions + - The `LangchainKnowledge` class automatically handles version differences + +3. **Verify the upgrade:** + +```bash +python -c "import langchain; print(langchain.__version__)" +``` + +#### Staying on LangChain 0.3.x + +If you need to stay on version 0.3.x: + +```bash +pip install "langchain>=0.3.0,<1.0.0" "langchain-core>=0.3.0,<1.0.0" +``` + +### Best Practices + +1. **Use stable APIs from langchain-core:** + - `langchain_core.prompts` + - `langchain_core.documents` + - `langchain_core.vectorstores` + - `langchain_core.retrievers` + +2. **Use compatible imports for Text Splitters:** + +```python +try: + # Import for langchain v1.x.x + from langchain_text_splitters import RecursiveCharacterTextSplitter +except ImportError: + # Import for langchain v0.3.x + from langchain.text_splitter import RecursiveCharacterTextSplitter +``` + +3. **Avoid using Chain directly:** + - Prefer `Runnable` in new code + - `LangchainKnowledge` already handles this compatibility + +### FAQ + +#### Q: How to check the current LangChain version? + +```python +import langchain +print(f"LangChain version: {langchain.__version__}") +``` + +#### Q: Example code doesn't run after upgrading? + +Make sure all necessary sub-packages are installed: + +```bash +pip install langchain-core langchain-text-splitters langchain-community +``` + +#### Q: Do I need to modify existing code? + +No. All example code and the `LangchainKnowledge` class already handle version compatibility. + +### References + +- [LangChain Official Migration Guide](https://docs.langchain.com/oss/python/migrate/langchain-v1) +- [LangChain Core Documentation](https://python.langchain.com/docs/langchain_core/) +- [LangChain Text Splitters Documentation](https://python.langchain.com/docs/modules/data_connection/document_transformers/) + +## More + +- [Prompt Template](./knowledge_prompt_template.md) - RAG retrieval prompt template configuration +- [Document Loader](./knowledge_document_loader.md) - Configuration for loading knowledge sources from files, directories, URLs, etc. +- [Text Splitter](./knowledge_text_splitter.md) - Text splitter configuration +- [Vector Store](./knowledge_vectorstore.md) - Configure various vector database backends +- [Embedder](./knowledge_embedder.md) - Text vectorization model configuration +- [Retriever](./knowledge_retrievers.md) - Retriever configuration and reranking diff --git a/docs/mkdocs/en/knowledge_custom_components.md b/docs/mkdocs/en/knowledge_custom_components.md new file mode 100644 index 000000000..ca56d0157 --- /dev/null +++ b/docs/mkdocs/en/knowledge_custom_components.md @@ -0,0 +1,345 @@ +# Custom Langchain RAG Components + +This document describes how to customize core RAG components in `LangchainKnowledge`, including Document Loader, Text Splitter, Embeddings, and Retriever, for different scenarios. + +## Custom Document Loader + +1. Implement a custom Document Loader class + +Since `LangchainKnowledge` invokes `BaseLoader.aload()` to load documents, a custom Document Loader should inherit from `BaseLoader` (or its subclasses) and implement the required loading interfaces. In practice, because the default `aload()` implementation delegates to `alazy_load()` or `lazy_load()`, implementing either `lazy_load()` or `alazy_load()` is usually sufficient. + +```python +from typing import AsyncIterator, Iterator + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import Document + +class CustomDocumentLoader(BaseLoader): + """An example document loader that reads a file line by line.""" + + def __init__(self, file_path: str) -> None: + """Initialize the loader with a file path. + + Args: + file_path: The path to the file to load. + """ + self.file_path = file_path + + def lazy_load(self) -> Iterator[Document]: # <-- Does not take any arguments + """A lazy loader that reads a file line by line. + + When you're implementing lazy load methods, you should use a generator + to yield documents one by one. + """ + with open(self.file_path, encoding="utf-8") as f: + line_number = 0 + for line in f: + yield Document( + page_content=line, + metadata={"line_number": line_number, "source": self.file_path}, + ) + line_number += 1 + + # alazy_load is OPTIONAL. + # If you leave out the implementation, a default implementation which delegates to lazy_load will be used! + async def alazy_load( + self, + ) -> AsyncIterator[Document]: # <-- Does not take any arguments + """An async lazy loader that reads a file line by line.""" + # Requires aiofiles + # https://github.com/Tinche/aiofiles + import aiofiles + + async with aiofiles.open(self.file_path, encoding="utf-8") as f: + line_number = 0 + async for line in f: + yield Document( + page_content=line, + metadata={"line_number": line_number, "source": self.file_path}, + ) + line_number += 1 +``` + +2. Construct a `LangchainKnowledge` object with the custom Document Loader + +```python +rag = LangchainKnowledge( + ..., + document_loader=CustomDocumentLoader(file_path), + ..., +) +``` + +## Custom Text Splitter + +1. Implement a custom Text Splitter class + +Since `LangchainKnowledge` invokes `BaseDocumentTransformer.atransform_documents()` to process documents, a custom Text Splitter should inherit from `BaseDocumentTransformer` (or its subclasses) and implement the required transformation interfaces. Because the default `atransform_documents()` implementation calls `transform_documents()`, implementing `transform_documents()` is usually enough. + +The following is an example of splitting text by a separator. For the complete example, see [knowledge_with_custom_components](../../../examples/knowledge_with_custom_components/): + +```python +from typing import Any, Sequence + +from langchain_core.documents import BaseDocumentTransformer, Document + +class CustomTextSplitter(BaseDocumentTransformer): + """Interface for splitting text into chunks.""" + + def __init__( + self, + separator: str + ) -> None: + """Create a new TextSplitter.""" + self.separator = separator + + def transform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + """Transform a list of documents. + + Args: + documents: A sequence of Documents to be transformed. + + Returns: + A sequence of transformed Documents. + """ + transformed_docs = [] + + for doc in documents: + # Split the document content by separator + text_chunks = doc.page_content.split(self.separator) + + # Create new documents for each chunk + for i, chunk in enumerate(text_chunks): + # Skip empty chunks + if chunk.strip(): + # Create new document with the chunk content + new_doc = Document( + page_content=chunk.strip(), + metadata={ + **doc.metadata, # Preserve original metadata + "chunk_index": i, # Add chunk index + "original_doc_id": id(doc), # Reference to original document + } + ) + transformed_docs.append(new_doc) + + return transformed_docs + + async def atransform_documents( + self, documents: Sequence[Document], **kwargs: Any + ) -> Sequence[Document]: + """Asynchronously transform a list of documents. + + Args: + documents: A sequence of Documents to be transformed. + + Returns: + A sequence of transformed Documents. + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, self.transform_documents, documents, **kwargs + ) +``` + +2. Construct a `LangchainKnowledge` object with the custom Text Splitter + +```python +rag = LangchainKnowledge( + ..., + document_transformer=CustomTextSplitter("\n"), + ..., +) +``` + +## Custom Embeddings + +1. Implement a custom Embeddings class + +The approach to customizing embeddings is the same as [LangChain | Custom Embeddings](https://python.langchain.com/docs/how_to/custom_embeddings/). The following methods must be implemented: + +| Method/Property | Description | Required/Optional | +|---|---|---| +| embed_documents(texts) | Generates embeddings for a list of strings. | Required | +| embed_query(text) | Generates an embedding for a single text query. | Required | +| aembed_documents(texts) | Asynchronously generates embeddings for a list of strings. | Optional | +| aembed_query(text) | Asynchronously generates an embedding for a single text query. | Optional | + +The following is an example that converts text into a fixed vector (for illustration purposes only): + +```python +from typing import List + +from langchain_core.embeddings import Embeddings + + +class ParrotLinkEmbeddings(Embeddings): + """ParrotLink embedding model integration.""" + + def __init__(self, model: str): + self.model = model + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """Embed search docs.""" + return [[0.5, 0.6, 0.7] for _ in texts] + + def embed_query(self, text: str) -> List[float]: + """Embed query text.""" + return self.embed_documents([text])[0] + + # optional: add custom async implementations here + # you can also delete these, and the base class will + # use the default implementation, which calls the sync + # version in an async executor: + + # async def aembed_documents(self, texts: List[str]) -> List[List[float]]: + # """Asynchronous Embed search docs.""" + # ... + + # async def aembed_query(self, text: str) -> List[float]: + # """Asynchronous Embed query text.""" + # ... +``` + +2. Construct a `LangchainKnowledge` object with the custom Embeddings + +```python +rag = LangchainKnowledge( + ..., + embedder=ParrotLinkEmbeddings("xxx"), + ..., +) +``` + +## Custom Retriever + +1. Implement a custom Retriever class + +The LangchainKnowledge class invokes the `ainvoke` method of the `BaseRetriever` class to perform retrieval. When both retriever and vectorstore are used together, the `from_documents` method of the `BaseRetriever` class is called to create the retriever from the vectorstore's indexed results. + +The approach to customizing a Retriever is the same as [LangChain | How to create a custom Retriever](https://python.langchain.com/docs/how_to/custom_retriever/). The following methods must be implemented: + +| Method/Property | Description | Required/Optional | +|---|---|---| +| _get_relevant_documents | Get documents relevant to a query. | Required | +| _aget_relevant_documents | Implement to provide async native support. | Optional | + +The following is an example of a retriever that "returns all documents whose text contains the text from the user query". For the complete example, see [knowledge_with_custom_components](../../../examples/knowledge_with_custom_components/): + +```python +from typing import List + +from langchain_core.callbacks import CallbackManagerForRetrieverRun +from langchain_core.documents import Document +from langchain_core.retrievers import BaseRetriever + + +class ToyRetriever(BaseRetriever): + """A toy retriever that contains the top k documents that contain the user query. + + This retriever only implements the sync method _get_relevant_documents. + + If the retriever were to involve file access or network access, it could benefit + from a native async implementation of `_aget_relevant_documents`. + + As usual, with Runnables, there's a default async implementation that's provided + that delegates to the sync implementation running on another thread. + """ + + documents: List[Document] + """List of documents to retrieve from.""" + k: int + """Number of top results to return""" + + def _get_relevant_documents( + self, query: str, *, run_manager: CallbackManagerForRetrieverRun + ) -> List[Document]: + """Sync implementations for retriever.""" + matching_documents = [] + for document in self.documents: + if len(matching_documents) >= self.k: + return matching_documents + + if query.lower() in document.page_content.lower(): + matching_documents.append(document) + return matching_documents + + # Optional: Provide a more efficient native implementation by overriding + # _aget_relevant_documents + # async def _aget_relevant_documents( + # self, query: str, *, run_manager: AsyncCallbackManagerForRetrieverRun + # ) -> List[Document]: + # """Asynchronously get documents relevant to a query. + + # Args: + # query: String to find relevant documents for + # run_manager: The callbacks handler to use + + # Returns: + # List of relevant documents + # """ +``` + +Additionally, if this Retriever needs to be used together with a vectorstore, it is required to have a `from_documents` interface. An example is shown below: + +```python + # Optional: If you want to use retriever with vectorstore together in LangChainKnowledge, + # you should implement this method + @classmethod + def from_documents( + cls, + documents: Iterable[Document], + **kwargs: Any, + ) -> "ToyRetriever": + """ + Create a ToyRetriever from a list of Documents. + Args: + documents: A list of Documents to vectorize. + **kwargs: Any other arguments to pass to the retriever. + + Returns: + A ToyRetriever instance. + """ + # Extract k parameter from kwargs, default to 3 + k = kwargs.pop('k', 3) + + # Convert documents to list if it's an iterable + doc_list = list(documents) + + # Create and return ToyRetriever instance + return cls(documents=doc_list, k=k, **kwargs) +``` + +2. Construct a `LangchainKnowledge` object with the custom Retriever + +```python +test_documents = [ + Document( + page_content="Shenzhen: sunny", + metadata={"source": "weather.txt"} + ), + Document( + page_content="Shanghai: cloud", + metadata={"source": "weather.txt"} + ) +] +retriever = ToyRetriever(test_documents, k=3) +rag = LangchainKnowledge( + ..., + retriever=retriever, + ..., +) +``` + +## Complete Example + +For the complete example, see [knowledge_with_custom_components](../../../examples/knowledge_with_custom_components/). + +## References + +- [How to create a custom Document Loader](https://python.langchain.com/docs/how_to/document_loader_custom/) +- [how_to/#custom](https://python.langchain.com/docs/how_to/#custom) +- [Custom Embeddings](https://python.langchain.com/docs/how_to/custom_embeddings/) +- [How to create a custom Retriever](https://python.langchain.com/docs/how_to/custom_retriever/) diff --git a/docs/mkdocs/en/knowledge_document_loader.md b/docs/mkdocs/en/knowledge_document_loader.md new file mode 100644 index 000000000..268931eef --- /dev/null +++ b/docs/mkdocs/en/knowledge_document_loader.md @@ -0,0 +1,138 @@ +# Document Loaders + +Document Loaders read raw data from various sources (text files, PDFs, Markdown, etc.) and convert it into the standard LangChain `Document` format for downstream text splitting, vectorization, and retrieval. + +Each Document Loader has its own parameters, but they all expose a unified `.load` interface. + +Below is an introduction to some commonly used components: + +- [TextLoader](#textloader) +- [PyPDFLoader](#pypdfloader) +- [UnstructuredMarkdownLoader](#unstructuredmarkdownloader) + +For more components, refer to [Langchain Document loaders](https://python.langchain.com/docs/integrations/document_loaders/). + +## TextLoader + +### Install Dependencies + +`TextLoader` is included in the `langchain-community` package. If it is not installed, use the following command: + +```shell +pip install langchain-community +``` + +### Usage + +1. Create a `TextLoader` object + +```python +import tempfile +from langchain_community.document_loaders import TextLoader + +# Write text content to a temporary file and load it +text_content = "Artificial Intelligence (AI) is a branch of computer science..." +tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w", encoding="utf-8") +tmp_file.write(text_content) +tmp_file.flush() +tmp_file.close() + +# Create a TextLoader instance, specifying the temporary file path and encoding +text_loader = TextLoader(tmp_file.name, encoding="utf-8") +``` + +2. Construct a `LangchainKnowledge` object using this `text_loader` object + +```python +rag = LangchainKnowledge( + ..., + document_loader=text_loader, + ..., +) +``` + +### Reference + +- [langchain_community.document_loaders.text.TextLoader](https://python.langchain.com/api_reference/community/document_loaders/langchain_community.document_loaders.text.TextLoader.html) + + +## PyPDFLoader + +### Install Dependencies + +```shell +pip install -qU pypdf +``` + +### Usage + +1. Create a `PyPDFLoader` object + +```python +import os +from langchain_community.document_loaders import PyPDFLoader + +# Get the PDF file path from environment variables +pdf_path = os.getenv("DOCUMENT_PDF_PATH", "/path/to/your/file.pdf") +loader = PyPDFLoader(pdf_path) +``` + +2. Construct a `LangchainKnowledge` object using this loader object + +```python +rag = LangchainKnowledge( + ..., + document_loader=loader, + ..., +) +``` + +### Reference + +- [How to load PDFs](https://python.langchain.com/docs/how_to/document_loader_pdf/) + + +## UnstructuredMarkdownLoader + +### Install Dependencies + +```shell +pip install -qU langchain_community unstructured +``` + +### Usage + +1. Create an `UnstructuredMarkdownLoader` object + +```python +import tempfile +from langchain_community.document_loaders import UnstructuredMarkdownLoader + +# Write Markdown content to a temporary file and load it +md_content = "# Introduction to Artificial Intelligence\n\nArtificial Intelligence is a branch of computer science..." +tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".md", mode="w", encoding="utf-8") +tmp_file.write(md_content) +tmp_file.flush() +tmp_file.close() + +# mode="single" treats the entire file as a single Document; strategy="fast" uses the fast parsing strategy +loader = UnstructuredMarkdownLoader(tmp_file.name, mode="single", strategy="fast") +``` + +2. Construct a `LangchainKnowledge` object using this loader object + +```python +rag = LangchainKnowledge( + ..., + document_loader=loader, + ..., +) +``` + +### Reference + +- [UnstructuredMarkdownLoader](https://python.langchain.com/docs/integrations/document_loaders/unstructured_markdown/) + +## Full Example + +For a complete example, see [/examples/knowledge_with_documentloader/run_agent.py](../../../examples/knowledge_with_documentloader/run_agent.py). diff --git a/docs/mkdocs/en/knowledge_embedder.md b/docs/mkdocs/en/knowledge_embedder.md new file mode 100644 index 000000000..664009cea --- /dev/null +++ b/docs/mkdocs/en/knowledge_embedder.md @@ -0,0 +1,96 @@ +# Embedder + +Embedders map unstructured data such as text and images into high-dimensional vector representations, enabling semantic similarity computation and comparison. They are a core component of semantic search in knowledge retrieval systems. + +Below is an introduction to some commonly used components: + +- [HuggingFaceEmbeddings](#huggingfaceembeddings) +- [HunyuanEmbeddings](#hunyuanembeddings) + +For more components, refer to [Langchain Embedding models](https://python.langchain.com/docs/integrations/text_embedding/). + +## HuggingFaceEmbeddings + +### Install Dependencies + +```shell +pip install --upgrade --quiet langchain langchain-huggingface sentence_transformers +``` + +### Usage + +1. Create a `HuggingFaceEmbeddings` object + +```python +from langchain_huggingface import HuggingFaceEmbeddings + +# Specify the HuggingFace model name to use +model_name = "BAAI/bge-small-en-v1.5" +# Specify model loading parameters; here it is set to run on CPU +model_kwargs = {"device": "cpu"} +# Specify encoding parameters; here it is set to normalize the output embeddings +encode_kwargs = {"normalize_embeddings": True} +# Create the HuggingFaceEmbeddings embedder object +embedder = HuggingFaceEmbeddings( + model_name=model_name, + model_kwargs=model_kwargs, + encode_kwargs=encode_kwargs +) +``` + +2. Construct a `LangchainKnowledge` object using this `embedder` object + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, # Pass the constructed embedder + vectorstore=vectorstore, +) +``` + +### Reference + +- [Hugging Face](https://python.langchain.com/docs/integrations/providers/huggingface/) + + +## HunyuanEmbeddings + +### Install Dependencies + +```shell +pip install hunyuan langchain-community +pip install "tencentcloud-sdk-python>=3.0.1139" +``` + +### Usage + +1. Create a `HunyuanEmbeddings` object + +```python +from langchain_community.embeddings import HunyuanEmbeddings + +embedder = HunyuanEmbeddings( + hunyuan_secret_id="xxx", # Hunyuan Secret ID, or set via the HUNYUAN_SECRET_ID environment variable + hunyuan_secret_key="xxx", # Hunyuan Secret Key, or set via the HUNYUAN_SECRET_KEY environment variable + region="ap-guangzhou" # Region of the Hunyuan service +) +``` + +2. Construct a `LangchainKnowledge` object using this `embedder` object + +```python +rag = LangchainKnowledge( + ..., + embedder=embedder, + ..., +) +``` + +### Reference + +- [langchain_community.embeddings.hunyuan.HunyuanEmbeddings](https://python.langchain.com/api_reference/community/embeddings/langchain_community.embeddings.hunyuan.HunyuanEmbeddings.html) +- [Tencent Hunyuan LLM > Quick Start](https://cloud.tencent.com/document/product/1729/97730) diff --git a/docs/mkdocs/en/knowledge_prompt_template.md b/docs/mkdocs/en/knowledge_prompt_template.md new file mode 100644 index 000000000..56fa5e4f4 --- /dev/null +++ b/docs/mkdocs/en/knowledge_prompt_template.md @@ -0,0 +1,145 @@ +# Prompt Templates + +Prompt Templates bridge user input and Large Language Models (LLMs). They organize raw input and related parameters into structured instructions that models can reliably follow, improving response quality and consistency in specific contexts. In RAG scenarios, Prompt Templates are especially important: well-designed templates can effectively combine retrieved knowledge with user questions and significantly improve answer quality. + +Below is an introduction to some commonly used template components: + +- [PromptTemplate (StringPromptTemplate)](#prompttemplate-stringprompttemplate) +- [ChatPromptTemplate](#chatprompttemplate) +- [MessagesPlaceholder](#messagesplaceholder) + +For more component usage details, see [Langchain Prompt Templates](https://python.langchain.com/docs/concepts/prompt_templates/). + +## Installation + +The packages related to Prompt Templates are provided by `langchain-core`, which is a dependency of `langchain`. + +After installing the trpc-python-agent framework, the related dependencies are automatically installed, so no further installation is required. + +## PromptTemplate (StringPromptTemplate) + +### Usage + +1. Create a `PromptTemplate` object + +`PromptTemplate` is used to format a single string, typically for simple inputs. It concatenates retrieval results with user questions through `{context}` and `{query}` placeholders. + +```python +from langchain_core.prompts import PromptTemplate + +prompt = PromptTemplate.from_template( + "Please answer the user's question based on the following retrieved context.\n" + "Context: {context}\n" + "Question: {query}\n" + "Answer:" +) +``` + +2. Construct a `LangchainKnowledge` object based on this prompt object + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + prompt_template=prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, +) +``` + +### Reference + +- [PromptTemplate](https://python.langchain.com/docs/concepts/prompt_templates/) + + +## ChatPromptTemplate + +### Usage + +1. Create a `ChatPromptTemplate` object + +`ChatPromptTemplate` is used to format a list of messages, composed of a list of templates. It supports `system` / `user` role separation, allowing the model to clearly distinguish between system instructions and user input. + +```python +from langchain_core.prompts import ChatPromptTemplate + +prompt = ChatPromptTemplate([ + ("system", "You are a knowledge Q&A assistant. Please answer questions based on the provided context. If the context does not contain relevant information, please state so honestly."), + ("user", "Context: {context}\n\nQuestion: {query}"), +]) +``` + +2. Construct a `LangchainKnowledge` object based on this prompt object + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + prompt_template=prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, +) +``` + +### Reference + +- [ChatPromptTemplate](https://python.langchain.com/docs/concepts/prompt_templates/) + +## MessagesPlaceholder + +### Usage + +1. Create a prompt object containing `MessagesPlaceholder` + +`MessagesPlaceholder` is used to insert a list of messages at a specific position, suitable for multi-turn conversation scenarios that require preserving conversation history. + +```python +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +prompt = ChatPromptTemplate([ + ("system", "You are a knowledge Q&A assistant. Please answer questions based on the provided context and conversation history."), + MessagesPlaceholder("chat_history"), + ("user", "Context: {context}\n\nQuestion: {query}"), +]) +``` + +`MessagesPlaceholder("chat_history")` will be replaced with the actual conversation history message list at invocation time: + +```python +from langchain_core.messages import HumanMessage, AIMessage + +prompt.invoke({ + "chat_history": [ + HumanMessage(content="What is artificial intelligence?"), + AIMessage(content="Artificial intelligence is a branch of computer science..."), + ], + "context": "Deep learning is a subfield of machine learning...", + "query": "What is the relationship between deep learning and machine learning?", +}) +``` + +2. Construct a `LangchainKnowledge` object based on this prompt object + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + prompt_template=prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, +) +``` + +### Reference + +- [MessagesPlaceholder](https://python.langchain.com/docs/concepts/prompt_templates/) + +## Complete Example + +For a complete example, see: [examples/knowledge_with_prompt_template/run_agent.py](../../../examples/knowledge_with_prompt_template/run_agent.py) diff --git a/docs/mkdocs/en/knowledge_retrievers.md b/docs/mkdocs/en/knowledge_retrievers.md new file mode 100644 index 000000000..9bcef2e8f --- /dev/null +++ b/docs/mkdocs/en/knowledge_retrievers.md @@ -0,0 +1,95 @@ +# Retrievers + +Retrievers are a generic LangChain interface for returning relevant documents for a query. Unlike vector stores, retrievers do not need document storage capabilities; they only retrieve and return documents. Retrievers can be built on top of vector stores and can also use other backends, such as [Wikipedia search](https://python.langchain.com/docs/integrations/retrievers/wikipedia/) and [Amazon Kendra](https://python.langchain.com/docs/integrations/retrievers/amazon_kendra_retriever/). + +Depending on the type of retriever used, there are several ways to create a retriever: + +- [Create Retriever from Vector Store](#create-retriever-from-vector-store) +- [BM25Retriever](#bm25retriever) + +For more component usage details, see [LangChain Retrievers](https://python.langchain.com/docs/integrations/retrievers/). + +## Create Retriever from Vector Store + +### Usage + +1. Create a `retriever` object + +You can directly instantiate a retriever from a vectorstore instance: + +```python +retriever = vectorstore.as_retriever() # Use the vectorstore's as_retriever method + +docs = retriever.invoke("your-question?") # Perform retrieval +``` + +You can also specify the search type and additional search parameters. For details, refer to [How to use a vectorstore as a retriever](https://python.langchain.com/docs/how_to/vectorstore_retriever/). + +2. Construct a `LangchainKnowledge` object using this `retriever` object + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + ..., + retriever=retriever, + ..., +) +``` + +> **Note:** If a `vectorstore` is already in use, the `retriever` is not required. If both `vectorstore` and `retriever` are used simultaneously, the `retriever` will re-rank the results from the `vectorstore` before outputting the retrieval results. In this case, the `retriever` object must have a `from_documents` interface (used to create a retrieval set from vectorstore results). + +### Reference + +- [How to use a vectorstore as a retriever](https://python.langchain.com/docs/how_to/vectorstore_retriever/) + +## BM25Retriever + +### Installation + +```shell +pip install --upgrade --quiet rank_bm25 +``` + +### Usage + +1. Create a `BM25Retriever` object + +```python +from langchain_community.retrievers import BM25Retriever +from langchain_core.documents import Document + +# Create a retriever using BM25Retriever's from_texts method +# Given some example document contents ["foo", "bar"] +retriever = BM25Retriever.from_texts(["foo", "bar"]) + +# Or create using from_documents +# retriever = BM25Retriever.from_documents( +# [ +# Document(page_content="foo"), +# Document(page_content="bar"), +# ] +# ) +``` + +2. Construct a `LangchainKnowledge` object using this `retriever` object + +```python +rag = LangchainKnowledge( + ..., + retriever=retriever, + ..., +) +``` + +### Reference + +- [BM25Retriever](https://python.langchain.com/docs/integrations/retrievers/bm25/) + +## Complete Example + +Please refer to [examples/knowledge_with_rag_agent/README.md](../../../examples/knowledge_with_rag_agent/README.md). + +## More + +For more Retriever component usage details, refer to: [LangChain Retrievers](https://python.langchain.com/docs/integrations/retrievers/). diff --git a/docs/mkdocs/en/knowledge_text_splitter.md b/docs/mkdocs/en/knowledge_text_splitter.md new file mode 100644 index 000000000..246649c8b --- /dev/null +++ b/docs/mkdocs/en/knowledge_text_splitter.md @@ -0,0 +1,98 @@ +# Text Splitters + +Text Splitters are responsible for splitting large documents into smaller text chunks suitable for retrieval. A well-designed text splitting strategy can effectively improve the retrieval accuracy and generation quality of a RAG system — by controlling chunk size and preserving semantic integrity, ensuring each text chunk contains meaningful contextual information. + +Below is an introduction to some commonly used components: + +- [MarkdownHeaderTextSplitter](#markdownheadertextsplitter) +- [RecursiveCharacterTextSplitter](#recursivecharactertextsplitter) + +For more component usage details, see [Text splitters](https://python.langchain.com/docs/how_to/#text-splitters). + +## Install Dependencies + +MarkdownHeaderTextSplitter resides in different packages depending on the LangChain version: +- **LangChain 1.x.x**: `langchain-text-splitters` package +- **LangChain 0.3.x**: `langchain.text_splitter` module + +After installing tRPC-Agent-Python, the relevant dependencies are installed automatically, so no further installation is required. + +## MarkdownHeaderTextSplitter + +### Usage + +1. Create a `MarkdownHeaderTextSplitter` object + +```python +# Import compatible with both LangChain 0.3.x and 1.x.x +try: + from langchain_text_splitters import MarkdownHeaderTextSplitter +except ImportError: + from langchain.text_splitter import MarkdownHeaderTextSplitter + +headers_to_split_on = [ + ("#", "Header 1"), + ("##", "Header 2"), + ("###", "Header 3"), +] + +markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on) +``` + +2. Construct a `LangchainKnowledge` object using this `markdown_splitter` object + +```python +rag = LangchainKnowledge( + ..., + document_transformer=markdown_splitter, + ..., +) +``` + +### References + +- [How to split Markdown by Headers](https://python.langchain.com/docs/how_to/markdown_header_metadata_splitter/) + + +## RecursiveCharacterTextSplitter + +### Usage + +1. Create a `RecursiveCharacterTextSplitter` object + +```python +# Import compatible with both LangChain 0.3.x and 1.x.x +try: + # Import for langchain v1.x.x + from langchain_text_splitters import RecursiveCharacterTextSplitter +except ImportError: + # Import for langchain v0.3.x + from langchain.text_splitter import RecursiveCharacterTextSplitter + +# chunk_size specifies the maximum number of characters per text chunk, chunk_overlap specifies the number of overlapping characters between adjacent chunks. +# Adjust these two parameters based on actual text length and use case to achieve optimal chunking results. +text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) +``` + +2. Construct a `LangchainKnowledge` object using this `text_splitter` object + +```python +# examples/knowledge_with_rag_agent/agent/tools.py +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +rag = LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, +) +``` + +### References + +- [How to recursively split text by characters](https://python.langchain.com/docs/how_to/recursive_text_splitter/) + +## Complete Example + +Please refer to [examples/knowledge_with_rag_agent/README.md](../../../examples/knowledge_with_rag_agent/README.md). diff --git a/docs/mkdocs/en/knowledge_vectorstore.md b/docs/mkdocs/en/knowledge_vectorstore.md new file mode 100644 index 000000000..ddd113424 --- /dev/null +++ b/docs/mkdocs/en/knowledge_vectorstore.md @@ -0,0 +1,646 @@ +# VectorStore + +The tRPC-Agent-Python framework supports multiple vector store backends through `LangchainKnowledge`. A VectorStore stores vectorized representations (embeddings) of text and supports efficient similarity-based retrieval. It is a core component for building RAG (Retrieval-Augmented Generation) applications. This document explains how to integrate and use the following vector backends in the framework: + +- [PGVector](#pgvector) +- [Elasticsearch](#elasticsearch) +- [Tencent Cloud VectorDB](#tencent-cloud-vectordb) + +## PGVector + +### Install Dependencies + +```bash +pip install -qU langchain-postgres +``` + +### Usage + +Create a `PGVector` object and construct a `LangchainKnowledge`: + +```python +from trpc_agent_sdk.server.knowledge.langchain_knowledge import LangchainKnowledge + +def _build_pgvector_knowledge() -> LangchainKnowledge: + """Build knowledge with PGVector vectorstore""" + from langchain_huggingface import HuggingFaceEmbeddings + from langchain_postgres import PGVector + + config = get_pgvector_config() + # Initialize the embedding model to convert text into vector representations + embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + # Create a PGVector instance; use_jsonb=True stores metadata in JSONB format, enabling structured filtering + vectorstore = PGVector( + embeddings=embedder, + collection_name=config["collection_name"], + connection=config["connection"], + use_jsonb=True, + ) + text_loader = TextLoader(KNOWLEDGE_FILE, encoding="utf-8") + # chunk_size and chunk_overlap control document chunking granularity, affecting retrieval accuracy + text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) + + # Assemble all components into LangchainKnowledge for unified document loading, splitting, vectorization, and retrieval + return LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, + ) +``` + +Note: Ensure that a PostgreSQL database with the pgvector extension is running. Connection parameters are configured via environment variables. See `get_pgvector_config()` in [`examples/knowledge_with_vectorstore/agent/config.py`](../../../examples/knowledge_with_vectorstore/agent/config.py) for details. + +### How to Build a Vector Database from Documents + +If the knowledge base has already been built on PGVector, you can skip this section and proceed directly to retrieval. Otherwise, follow the steps below to build it. The following methods are supported: + +1) Use the `LangchainKnowledge` instance method `create_vectorstore_from_document` to build the vector store + +```python +# examples/knowledge_with_vectorstore/run_agent.py +from agent.tools import rag + +# Create the vector database from documents (skip this step if the knowledge base is already built) +await rag.create_vectorstore_from_document() +``` + +2) Use the `PGVector` instance method `add_documents` to add data to the vector store + +Here is a simple example: + +```python +from langchain_core.documents import Document + +docs = [ + Document( + page_content="there are cats in the pond", + metadata={"id": 1, "location": "pond", "topic": "animals"}, + ), + Document( + page_content="ducks are also found in the pond", + metadata={"id": 2, "location": "pond", "topic": "animals"}, + ), + Document( + page_content="fresh apples are available at the market", + metadata={"id": 3, "location": "market", "topic": "food"}, + ), +] + +# Add documents to the vector database; ids are used for deduplication and subsequent updates +vectorstore.add_documents(docs, ids=[doc.metadata["id"] for doc in docs]) +``` + +3) Use the `PGVector` class method `from_documents` to build the vector store directly + +```python +from langchain_community.document_loaders import TextLoader +from langchain_huggingface import HuggingFaceEmbeddings +from langchain_text_splitters import CharacterTextSplitter +from langchain_postgres import PGVector +from agent.config import get_pgvector_config + +config = get_pgvector_config() + +# Load and split documents +loader = TextLoader("test.txt", encoding="utf-8") +documents = loader.load() +text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0) +docs = text_splitter.split_documents(documents) + +embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + +# from_documents performs vectorization and database insertion in one step, returning a queryable vectorstore instance +vectorstore = PGVector.from_documents( + documents=docs, + embedding=embedder, + collection_name=config["collection_name"], + connection=config["connection"], + use_jsonb=True, +) +``` + +### How to Perform Retrieval Using the Vector Database + +1) Use the `LangchainKnowledge` instance method `search` + +```python +# examples/knowledge_with_vectorstore/agent/tools.py +async def simple_search(query: str): + """Search the knowledge base for relevant documents""" + metadata = { + 'assistant_name': 'test', + 'runnable_config': {}, + } + # Create an Agent context; timeout sets the retrieval timeout in milliseconds + ctx = new_agent_context(timeout=3000, metadata=metadata) + # Wrap the query text as a SearchRequest + sr: SearchRequest = SearchRequest() + sr.query = Part.from_text(text=query) + # Perform vector similarity retrieval, returning a list of documents sorted by relevance + search_result: SearchResult = await rag.search(ctx, sr) + if len(search_result.documents) == 0: + return {"status": "failed", "report": "No documents found"} + + # Retrieve the top document with the highest similarity + best_doc = search_result.documents[0].document + return {"status": "success", "report": f"content: {best_doc.page_content}"} +``` + +2) Use the `PGVector` instance method `similarity_search` + +```python +results = vectorstore.similarity_search( + query="LangChain provides abstractions to make working with LLMs easy", + k=2, # Return Top-K most similar results + filter=[{"term": {"metadata.source.keyword": "tweet"}}], # Filter based on metadata +) +for res in results: + print(f"* {res.page_content} [{res.metadata}]") +``` + +### How to Create a Retriever from the Vector Database + +Use the `PGVector` instance method `as_retriever` to obtain a retriever + +```python +# search_type="mmr" uses the Maximal Marginal Relevance algorithm, balancing relevance and diversity +retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": 1}) +retriever.invoke("kitty") +``` + +### References + +- [LangChain PGVector](https://python.langchain.com/docs/integrations/vectorstores/pgvector/) + +--- + +## Elasticsearch + +### Install Dependencies + +```bash +pip install -qU langchain-elasticsearch +``` + +### Usage + +Create an `ElasticsearchStore` object and construct a `LangchainKnowledge` (full code available in `examples/knowledge_with_vectorstore/agent/tools.py`): + +```python +def _build_elasticsearch_knowledge() -> LangchainKnowledge: + """Build knowledge with Elasticsearch vectorstore""" + from langchain_elasticsearch import ElasticsearchStore + from langchain_huggingface import HuggingFaceEmbeddings + + config = get_elasticsearch_config() + embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + # Create an ElasticsearchStore instance, authenticating via es_api_key + vectorstore = ElasticsearchStore( + es_url=config["es_url"], + index_name=config["index_name"], + embedding=embedder, + es_api_key=config["es_api_key"], + ) + text_loader = TextLoader(KNOWLEDGE_FILE, encoding="utf-8") + text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) + + # Assemble into LangchainKnowledge; usage is identical to PGVector, only the vectorstore backend differs + return LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=embedder, + vectorstore=vectorstore, + ) +``` + +Note: Connection parameters are configured via environment variables. See `get_elasticsearch_config()` in [`examples/knowledge_with_vectorstore/agent/config.py`](../../../examples/knowledge_with_vectorstore/agent/config.py) for details. + +### How to Build a Vector Database from Documents + +If the knowledge base has already been built on Elasticsearch, you can skip this section and proceed directly to retrieval. Otherwise, follow the steps below to build it. The following methods are supported: + +1) Use the `LangchainKnowledge` instance method `create_vectorstore_from_document` to build the vector store + +```python +# examples/knowledge_with_vectorstore/run_agent.py +from agent.tools import rag, get_create_vectorstore_kwargs + +# Elasticsearch requires additional connection parameters, automatically generated by get_create_vectorstore_kwargs() +await rag.create_vectorstore_from_document(**get_create_vectorstore_kwargs()) +``` + +`get_create_vectorstore_kwargs()` returns the following parameters for Elasticsearch: +```python +def get_create_vectorstore_kwargs() -> dict: + """Return extra kwargs for create_vectorstore_from_document based on vectorstore type""" + vstore_type = get_vectorstore_type() + # ... + elif vstore_type == "elasticsearch": + config = get_elasticsearch_config() + # These parameters are passed to LangchainKnowledge.create_vectorstore_from_document() + return { + "es_url": config["es_url"], + "index_name": config["index_name"], + "es_api_key": config["es_api_key"], + } +``` + +2) Use the `ElasticsearchStore` instance method `add_documents` to build the vector store + +```python +import uuid +from agent.tools import rag + +async def create_vectorstore_from_document(): + # Asynchronously load raw documents + documents = await rag.document_loader.aload() + # Split documents into smaller chunks according to the configured splitting strategy + documents = await rag.document_transformer.atransform_documents(documents) + # Generate a unique ID for each document chunk to enable deduplication and updates + uuids = [str(uuid.uuid4()) for _ in range(len(documents))] + # Asynchronously vectorize document chunks and write them to the vector database + added_ids = await rag.vectorstore.aadd_documents(documents=documents, ids=uuids) + return added_ids +``` + +3) Use the `ElasticsearchStore` class method `from_documents` to build the vector store directly + +```python +from langchain_community.document_loaders import TextLoader +from langchain_elasticsearch import ElasticsearchStore +from langchain_huggingface import HuggingFaceEmbeddings +from langchain_text_splitters import CharacterTextSplitter +from agent.config import get_elasticsearch_config + +config = get_elasticsearch_config() + +# Load and split documents +loader = TextLoader("test.txt", encoding="utf-8") +documents = loader.load() +text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0) +docs = text_splitter.split_documents(documents) + +embedder = HuggingFaceEmbeddings(model_name="BAAI/bge-small-en-v1.5") + +# from_documents performs vectorization and writes to Elasticsearch in one step, returning a queryable vectorstore instance +vectorstore = ElasticsearchStore.from_documents( + documents=docs, + embedding=embedder, + es_url=config["es_url"], + index_name=config["index_name"], + es_api_key=config["es_api_key"], +) +``` + +### How to Perform Retrieval Using the Vector Database + +1) Use the `LangchainKnowledge` instance method `search` + +```python +# examples/knowledge_with_vectorstore/agent/tools.py +async def simple_search(query: str): + """Search the knowledge base for relevant documents""" + metadata = { + 'assistant_name': 'test', + 'runnable_config': {}, + } + # Create an Agent context; timeout sets the retrieval timeout in milliseconds + ctx = new_agent_context(timeout=3000, metadata=metadata) + # Wrap the query text as a SearchRequest + sr: SearchRequest = SearchRequest() + sr.query = Part.from_text(text=query) + # Perform vector similarity retrieval, returning a list of documents sorted by relevance + search_result: SearchResult = await rag.search(ctx, sr) + if len(search_result.documents) == 0: + return {"status": "failed", "report": "No documents found"} + + # Retrieve the top document with the highest similarity + best_doc = search_result.documents[0].document + return {"status": "success", "report": f"content: {best_doc.page_content}"} +``` + +2) Use the `ElasticsearchStore` instance method `similarity_search` + +```python +results = vectorstore.similarity_search( + query="LangChain provides abstractions to make working with LLMs easy", + k=2, # Return Top-K most similar results + filter=[{"term": {"metadata.source.keyword": "tweet"}}], # Filter based on metadata +) +for res in results: + print(f"* {res.page_content} [{res.metadata}]") +``` + +3) Configure retrieval strategy: use the `ElasticsearchStore` class method `from_documents` with the `strategy` parameter to set the retrieval strategy + +```python +from langchain_elasticsearch import DenseVectorStrategy +from agent.config import get_elasticsearch_config + +config = get_elasticsearch_config() + +vectorstore = ElasticsearchStore.from_documents( + documents=docs, + embedding=embedder, + es_url=config["es_url"], + index_name=config["index_name"], + es_api_key=config["es_api_key"], + strategy=DenseVectorStrategy(), # Use dense vector retrieval strategy; alternatives include SparseVectorStrategy, etc. +) + +docs = vectorstore.similarity_search( + query="What did the president say about Ketanji Brown Jackson?", k=10 +) +``` + +Note: For more retrieval strategies, see [LangChain Elasticsearch Retrieval Strategies](https://python.langchain.com/docs/integrations/vectorstores/elasticsearch/#retrieval-strategies) + +### How to Create a Retriever from the Vector Database + +1) Use the `ElasticsearchStore` instance method `as_retriever` to obtain a retriever + +```python +# similarity_score_threshold only returns results with similarity above the threshold, filtering out low-quality matches +retriever = vectorstore.as_retriever( + search_type="similarity_score_threshold", search_kwargs={"score_threshold": 0.2} +) +retriever.invoke("Stealing from the bank is a crime") +``` + +2) Use the `ElasticsearchRetriever` class method `from_es_params` to create a retriever + +```python +from langchain_elasticsearch import ElasticsearchRetriever + +# Custom vector query function that returns an Elasticsearch native KNN query DSL +def vector_query(search_query: str) -> Dict: + vector = embeddings.embed_query(search_query) # Use the same embedding model as during indexing + return { + "knn": { + "field": dense_vector_field, + "query_vector": vector, + "k": 5, # Return the 5 most similar results + "num_candidates": 10, # Candidate set size; larger values increase accuracy but reduce performance + } + } + +# Create a retriever via native ES parameters; body_func allows fully customized query logic +vector_retriever = ElasticsearchRetriever.from_es_params( + index_name=index_name, + body_func=vector_query, + content_field=text_field, + url=es_url, +) + +vector_retriever.invoke("foo") +``` + +Note: For more retriever types, see [LangChain Elasticsearch Retriever](https://python.langchain.com/docs/integrations/retrievers/elasticsearch_retriever/) + +### References + +- [LangChain Elasticsearch](https://python.langchain.com/docs/integrations/vectorstores/elasticsearch/) +- [LangChain Elasticsearch Retriever](https://python.langchain.com/docs/integrations/retrievers/elasticsearch_retriever/) + +--- + +## Tencent Cloud VectorDB + +### Install Dependencies + +```bash +pip3 install tcvectordb langchain-community +``` + +### Usage + +Create a `TencentVectorDB` object and construct a `LangchainKnowledge`: + +```python +def _build_tencentvdb_knowledge() -> LangchainKnowledge: + """Build knowledge with Tencent Cloud VectorDB""" + from langchain_community.vectorstores.tencentvectordb import ( + ConnectionParams, + IndexParams, + TencentVectorDB, + ) + + config = get_tencentvdb_config() + connection_params = ConnectionParams( + url=config["url"], + key=config["key"], + username=config["username"], + timeout=20, + ) + # dimension must match the output dimension of the embedding model being used + index_params = IndexParams(dimension=768, replicas=0) + # Tencent Cloud VectorDB supports server-side built-in embedding, so no external embedding model is needed; pass None + embeddings = None + vectorstore = TencentVectorDB( + embedding=embeddings, + connection_params=connection_params, + index_params=index_params, + database_name=config["database_name"], + collection_name=config["collection_name"], + t_vdb_embedding=config["t_vdb_embedding"], # Specify the server-side built-in embedding model + ) + text_loader = TextLoader(KNOWLEDGE_FILE, encoding="utf-8") + text_splitter = RecursiveCharacterTextSplitter(separators=["\n"], chunk_size=10, chunk_overlap=0) + + # Pass None for embedder since vectorization is handled by the Tencent Cloud server + return LangchainKnowledge( + prompt_template=rag_prompt, + document_loader=text_loader, + document_transformer=text_splitter, + embedder=None, + vectorstore=vectorstore, + ) +``` + +Note: Connection parameters are configured via environment variables. See `get_tencentvdb_config()` in [`examples/knowledge_with_vectorstore/agent/config.py`](../../../examples/knowledge_with_vectorstore/agent/config.py) for details. + +### How to Build a Vector Database from Documents + +If the knowledge base has already been built on Tencent Cloud VectorDB, you can skip this section and proceed directly to retrieval. Otherwise, follow the steps below to build it. The following methods are supported: + +1) Use the `LangchainKnowledge` instance method `create_vectorstore_from_document` to build the vector store + +```python +# examples/knowledge_with_vectorstore/run_agent.py +from agent.tools import rag, get_create_vectorstore_kwargs + +# Tencent Cloud VectorDB requires additional connection parameters, automatically generated by get_create_vectorstore_kwargs() +await rag.create_vectorstore_from_document(**get_create_vectorstore_kwargs()) +``` + +`get_create_vectorstore_kwargs()` returns the following parameters for Tencent Cloud VectorDB: + +```python +def get_create_vectorstore_kwargs() -> dict: + """Return extra kwargs for create_vectorstore_from_document based on vectorstore type""" + vstore_type = get_vectorstore_type() + if vstore_type == "tencentvdb": + from langchain_community.vectorstores.tencentvectordb import ( + ConnectionParams, + IndexParams, + ) + config = get_tencentvdb_config() + # These parameters are passed to LangchainKnowledge.create_vectorstore_from_document() + # to create a vector database collection on the Tencent Cloud server + return { + "embeddings": None, + "connection_params": ConnectionParams( + url=config["url"], + key=config["key"], + username=config["username"], + timeout=20, + ), + "index_params": IndexParams(dimension=768, replicas=0), + "database_name": config["database_name"], + "collection_name": config["collection_name"], + "t_vdb_embedding": config["t_vdb_embedding"], + } +``` + +2) Use the `TencentVectorDB` class method `from_documents` to build the vector store directly + +```python +from langchain_community.document_loaders import TextLoader +from langchain_community.vectorstores.tencentvectordb import ( + ConnectionParams, + TencentVectorDB, +) +from langchain_text_splitters import CharacterTextSplitter +from agent.config import get_tencentvdb_config + +config = get_tencentvdb_config() + +loader = TextLoader("test.txt", encoding="utf-8") +documents = loader.load() +text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0) +docs = text_splitter.split_documents(documents) + +conn_params = ConnectionParams( + url=config["url"], + key=config["key"], + username=config["username"], + timeout=20, +) + +# Use server-side built-in embedding; no local embedding model required +embeddings = None + +vector_db = TencentVectorDB.from_documents( + docs, + embeddings=embeddings, + connection_params=conn_params, + t_vdb_embedding=config["t_vdb_embedding"], # Specify the server-side embedding model name +) +``` + +Note: `from_documents` internally calls the `from_texts` interface, which supports additional parameters (such as database name, connection name, etc.). See the `from_texts` interface definition for details: + +```python + def from_texts( + cls, + texts: List[str], + embedding: Embeddings, + metadatas: Optional[List[dict]] = None, + connection_params: Optional[ConnectionParams] = None, + index_params: Optional[IndexParams] = None, + database_name: str = "LangChainDatabase", + collection_name: str = "LangChainCollection", + drop_old: Optional[bool] = False, + collection_description: Optional[str] = "Collection for LangChain", + meta_fields: Optional[List[MetaField]] = None, + t_vdb_embedding: Optional[str] = "bge-base-zh", + **kwargs: Any, + ) -> TencentVectorDB: +``` + +3) Use the `TencentVectorDB` instance method `add_texts` to insert data into the vector store + +```python +# If document IDs are not specified, they will be randomly generated. You can specify them via the ids: Optional[List[str]] parameter +vector_db.add_texts(["Ankush went to Princeton"]) +``` + +### How to Perform Retrieval Using the Vector Database + +1) Use the `LangchainKnowledge` instance method `search` + +```python +# examples/knowledge_with_vectorstore/agent/tools.py +async def simple_search(query: str): + """Search the knowledge base for relevant documents""" + metadata = { + 'assistant_name': 'test', + 'runnable_config': {}, + } + # Create an Agent context; timeout sets the retrieval timeout in milliseconds + ctx = new_agent_context(timeout=3000, metadata=metadata) + # Wrap the query text as a SearchRequest + sr: SearchRequest = SearchRequest() + sr.query = Part.from_text(text=query) + # Perform vector similarity retrieval, returning a list of documents sorted by relevance + search_result: SearchResult = await rag.search(ctx, sr) + if len(search_result.documents) == 0: + return {"status": "failed", "report": "No documents found"} + + # Retrieve the top document with the highest similarity + best_doc = search_result.documents[0].document + return {"status": "success", "report": f"content: {best_doc.page_content}"} +``` + +2) Use the `TencentVectorDB` instance method `similarity_search` + +```python +query = "What did the president say about Ketanji Brown Jackson" +docs = vector_db.similarity_search(query) +print(docs[0].page_content) +``` + +### References + +- [LangChain Tencent Cloud VectorDB](https://python.langchain.com/docs/integrations/vectorstores/tencentvectordb/) +- [Tencent Cloud VectorDB](https://cloud.tencent.com/document/product/1709) + +## Complete Example + +The complete example is available at [knowledge_with_vectorstore](../../../examples/knowledge_with_vectorstore/), supporting three backends: PGVector, Elasticsearch, and Tencent Cloud VectorDB. + +Steps to run the example: + +1. Configure environment variables in [examples/knowledge_with_vectorstore/.env](../../../examples/knowledge_with_vectorstore/.env), set `VECTORSTORE_TYPE=tencentvdb` and fill in the Tencent Cloud VectorDB connection parameters: + +```bash +VECTORSTORE_TYPE=tencentvdb +TENCENT_VDB_URL=http://10.0.X.X +TENCENT_VDB_KEY=your_key_here +TENCENT_VDB_USERNAME=root +TENCENT_VDB_DATABASE=LangChainDatabase +TENCENT_VDB_COLLECTION=LangChainCollection +TENCENT_VDB_EMBEDDING=bge-base-zh +``` + +2. Prepare the knowledge base documents + +The project uses `test.txt` as the default knowledge base document. You can replace it with your own document or specify a custom path via the `KNOWLEDGE_FILE` environment variable: + +```shell +echo "shenzhen weather: sunny +guangzhou weather: rain +shanghai weather: cloud" > test.txt +``` + +3. Run the example + +```shell +cd examples/knowledge_with_vectorstore/ +python3 run_agent.py +``` + +The program automatically builds the vector store from documents. The Agent then receives queries and invokes the `simple_search` tool for vector retrieval, generating answers from the retrieved results. diff --git a/docs/mkdocs/en/langgraph_agent.md b/docs/mkdocs/en/langgraph_agent.md new file mode 100644 index 000000000..d06c6dad6 --- /dev/null +++ b/docs/mkdocs/en/langgraph_agent.md @@ -0,0 +1,570 @@ +# LangGraph Agent + +LangGraphAgent wraps an AI Agent implementation based on LangGraph graph orchestration. It uses directed graphs to define complex workflows, supporting multi-step processing, conditional branching, parallel execution, and other advanced features. + +## Comparison with LlmAgent + +| Feature | LlmAgent | LangGraphAgent | +|---------|----------|----------------| +| **Use Cases** | Simple conversations, tool calling | Complex workflows, multi-step processing | +| **Execution Mode** | LLM-driven decision making | Graph-structured predefined workflows | +| **Control Granularity** | Coarse-grained (relies on LLM reasoning) | Fine-grained (precise control over each step) | +| **Parallel Processing** | Not supported | Natively supported | +| **Conditional Branching** | LLM autonomous judgment | Explicitly defined branching logic | +| **State Management** | Simple session state | Complex graph state management | +| **Predictability** | Low (depends on LLM) | High (predefined workflows) | + +## Scenario Recommendations + +**Use LangGraphAgent for:** +- Complex tasks requiring multi-step, sequential processing +- Workflows requiring conditional branching and parallel execution +- Scenarios requiring precise control over the execution flow +- State passing and transformation across multiple nodes +- Complex pipelines such as RAG, code generation, and data processing + +**Use LlmAgent for:** +- Simple conversations and Q&A +- Basic tool calling +- Scenarios where flexibility is more important than predictability + +## Installing LangGraph + +It is recommended to create an isolated virtual environment first, then install dependencies according to the versions specified in the project's `requirements-pypi.txt`. + +### Standalone Installation +```bash +# Create a Python virtual environment +python3 -m venv .venv +# Activate the virtual environment in the current directory +source .venv/bin/activate +# Install the specified version of LangGraph via the mirror source used in the project requirements +pip install "langgraph==0.6.0" +``` + +### Installation via requirements + +If you want dependencies that match the repository's current development environment, use the root-level `requirements.txt` and `requirements-pypi.txt` directly. + +```bash +# Create a Python virtual environment +python3 -m venv venv +# Activate the virtual environment in the current directory +source venv/bin/activate +# Then install project base dependencies +pip install -r requirements.txt +``` + +## Core Concepts + +In this framework, the typical usage of `LangGraphAgent` is: first build a workflow graph using LangGraph, then pass the graph object obtained after `compile()` to `LangGraphAgent`. Understanding the following core concepts will help you understand most examples. + +### Graph + +A graph is the core structure of a workflow, composed of nodes and edges, used to describe "where to start, what steps to go through, and under what conditions to flow where." Example code typically uses LangGraph's native `StateGraph` to construct graphs: + +```python +from langgraph.graph import StateGraph, START, END +from langgraph.prebuilt import ToolNode + +from trpc_agent_sdk.agents import langgraph_llm_node +from trpc_agent_sdk.dsl.graph import State + +class MyState(State): + result: str + +@langgraph_llm_node +def chatbot_node(state: State): + """Chatbot node that can use tools.""" + return {"messages": [llm_with_tools.invoke(state["messages"])]} + +graph_builder = StateGraph(State) + +# Add tool node +tool_node = ToolNode(tools=[]) +graph_builder.add_node("tools", tool_node) + +graph_builder = StateGraph(MyState) +graph_builder.add_node("chatbot", chatbot_node) +graph_builder.add_node("tools", tool_node) + +graph_builder.add_edge(START, "chatbot") +graph_builder.add_edge("tools", "chatbot") +graph_builder.add_edge("chatbot", END) +``` + +Additional notes: + +- `START` and `END` are special node identifiers provided by LangGraph, representing the entry point and exit point of the graph respectively +- They are built-in framework concepts and do not need to be implemented as separate functions like regular business nodes +- The graph itself is only responsible for describing the execution topology; the actual runtime logic is handled by each node function + +### Node + +A node represents a processing step in the workflow. A node typically corresponds to a function, which can be a regular processing node, an LLM node, or a tool execution node. + +In this framework, the common node implementation pattern is as follows: + +```python +from trpc_agent_sdk.agents import langgraph_llm_node + +@langgraph_llm_node +def chatbot_node(state: MyState, config): + return {"messages": [llm.invoke(state["messages"])]} +``` + +For tool nodes, they are typically used with the `@tool` and `@tool_node` decorators: + +```python +from langchain_core.tools import tool +from trpc_agent_sdk.agents import tool_node + +@tool +@tool_node +def calculate(operation: str, a: float, b: float) -> str: + return f"{operation}: {a}, {b}" +``` + +Additional notes: + +- The input to a node is typically the current `state` +- The return value of a node is typically a dictionary used to update the graph state +- `@langgraph_llm_node` adds tracing and event recording capabilities to LLM nodes +- `@tool_node` adds tool invocation chain recording capabilities to tool nodes + +### State + +State is the data container that flows between nodes. LangGraph examples in this framework typically use `TypedDict` to define the state structure; if a field needs to accumulate across multiple node executions, it can be combined with reducers like `Annotated[..., add_messages]`. + +```python +from typing import Annotated +from typing_extensions import TypedDict +from langgraph.graph.message import add_messages + +class MyState(TypedDict): + messages: Annotated[list, add_messages] + user_input: str + result: str +``` + +Additional notes: + +- `messages` is typically used to store conversation message history and is the most common state field in LangGraph conversational workflows +- Custom fields such as `user_input`, `result`, `status`, etc. can be used to store business data +- Nodes read upstream results via `state` and write back new state values by returning a dictionary + +### State Schema + +The "state schema" is typically the state type definition passed to `StateGraph(...)`. The most common approach is the `TypedDict` shown above. + +```python +graph_builder = StateGraph(MyState) +``` + +Its purpose is to: + +- Constrain which fields exist in the graph state +- Define the data type for each field +- Specify merge strategies for certain fields, e.g., appending to a message list instead of overwriting + +### Compiled Graph + +What `LangGraphAgent` actually accepts is not the `StateGraph` builder itself, but the graph object after `compile()`. The framework's `LangGraphAgent` explicitly requires a compiled graph as input. + +```python +from trpc_agent_sdk.agents import LangGraphAgent + +graph = graph_builder.compile() + +agent = LangGraphAgent( + name="workflow_agent", + graph=graph, + instruction="You are a workflow assistant.", +) +``` + +Additional notes: + +- `compile()` organizes the previously defined nodes, edges, and conditional routes into an executable graph +- `LangGraphAgent` invokes the streaming execution capability of this graph at runtime and converts LangGraph's output into `trpc_agent` `Event` objects +- Therefore, `StateGraph` can be understood as the "definition phase," and the compiled graph as the "execution phase" + +For more graph-related concepts, refer to: [Graph](./graph.md) + +## Creating a LangGraphAgent + +A LangGraphAgent can be created by providing a compiled LangGraph graph, as shown below: + +The other parameters follow the same definitions as in `LlmAgent`. + +```python +from trpc_agent_sdk.agents import LangGraphAgent + +# Assume the LangGraph has already been built +graph = build_your_langgraph() + +agent = LangGraphAgent( + name="workflow_agent", + description="A complex workflow processing agent", + graph=graph, + instruction="You are a workflow assistant that processes tasks step by step.", +) +``` + +## Building a LangGraph Workflow + +### Basic Graph Structure + +```python +from typing import Annotated +from typing_extensions import TypedDict +from langchain.chat_models import init_chat_model +from langchain_core.tools import tool +from langgraph.graph import StateGraph, START +from langgraph.graph.message import add_messages +from langgraph.prebuilt import tools_condition, ToolNode + +# Define the state structure +class State(TypedDict): + messages: Annotated[list, add_messages] + +# Initialize the model +model = init_chat_model("deepseek:deepseek-chat", api_key="your-api-key", api_base="https://api.deepseek.com/v1") + +def build_graph(): + graph_builder = StateGraph(State) + + # Add nodes + graph_builder.add_node("chatbot", chatbot_node) + graph_builder.add_node("tools", tool_node) + + # Add edges + graph_builder.add_edge(START, "chatbot") + graph_builder.add_conditional_edges("chatbot", tools_condition) + graph_builder.add_edge("tools", "chatbot") + + return graph_builder.compile() +``` + +### Integrating with trpc_agent + +The framework provides two important decorators to integrate LangGraph nodes with trpc_agent: + +#### @langgraph_llm_node Decorator + +Used to decorate nodes that call LLMs, automatically recording LLM invocation information: + +```python +from trpc_agent_sdk.agents import langgraph_llm_node + +@langgraph_llm_node +def chatbot_node(state: State): + """General LLM node that calls the LLM to generate responses""" + return {"messages": [llm_with_tools.invoke(state["messages"])]} + +# Usage with custom input/output keys +@langgraph_llm_node(input_key="conversation", output_key="response") +def custom_chatbot(state: CustomState): + """LLM node with custom input and output fields""" + return {"response": [llm.invoke(state["conversation"])]} +``` + +#### @tool_node Decorator + +Used to decorate tool execution nodes, automatically recording tool invocation information. Note that @tool_node must be placed after LangGraph's @tool decorator: + +```python +from trpc_agent_sdk.agents import tool_node +from langchain_core.tools import tool + +@tool +@tool_node +def calculate(operation: str, a: float, b: float) -> str: + """A tool for performing mathematical calculations""" + if operation == "add": + return f"Result: {a} + {b} = {a + b}" + # ... other operations +``` + +## Human-In-The-Loop Capability + +See [Human-In-The-Loop](./human_in_the_loop.md). + +## Advanced Configuration + +### LangGraph Configuration + +The framework provides `RunConfig` for LangGraph runtime settings, as shown below: +- `input`: Passes user-defined input. It is merged with the Agent's internal `{"messages": [xxx]}` and then used as input to `langgraph.astream`; +- [Stream Mode](https://langchain-ai.github.io/langgraph/how-tos/streaming/): Controls LangGraph output. The framework includes `updates`, `custom`, and `messages` by default, and you can add more; +- [RunnableConfig](https://python.langchain.com/api_reference/core/runnables/langchain_core.runnables.config.RunnableConfig.html): LangGraph runtime configuration that you can customize as needed; +- If additional options are needed, they can also be passed through `RunConfig`. At present, the framework only forwards `stream_mode` and `runnable_config`. Feel free to open an issue for more fields. + +```python +from trpc_agent_sdk.agents.run_config import RunConfig + +run_config = RunConfig( + agent_run_config={ + "input": {"user_input": {"custom1": "xxx"}}, + "stream_mode": ["values"], + "runnable_config": { + "configurable": {"xxx": "xxx"} + } + } +) + +runner.run_async(..., run_config=run_config) +``` + +### Retrieving Raw LangGraph Data + +After receiving an `Event`, you can access the raw LangGraph payload as shown below: + +```python +from trpc_agent_sdk.agents import get_langgraph_payload + +async for event in runner.run_async(...): + langgraph_payload = get_langgraph_payload(event) + if langgraph_payload: + stream_mode = langgraph_payload["stream_mode"] + chunk = langgraph_payload["chunk"] + # Process raw LangGraph data +``` + +### Accessing Agent Context within Nodes + +Within LangGraph nodes, you can access `trpc_agent` context information: + +```python +from trpc_agent_sdk.agents import get_langgraph_agent_context + +@langgraph_llm_node +def context_aware_node(state: State, config: RunnableConfig): + """A node that can access the Agent context""" + ctx = get_langgraph_agent_context(config) + user_id = ctx.session.user_id + session_state = ctx.session.state + + # Process based on context + response = llm.invoke(build_context_prompt(state, user_id, session_state)) + return {"messages": [response]} +``` + +## Memory Management Recommendations + +LangGraphAgent supports two memory management approaches: + +1. **Using trpc_agent's SessionService** (Recommended): + ```python + # Do not use a checkpointer; trpc_agent will manage session information + graph = graph_builder.compile() + ``` + +2. **Using LangGraph's checkpointer**: + ```python + from langgraph.checkpoint.memory import MemorySaver + + memory = MemorySaver() + graph = graph_builder.compile(checkpointer=memory) + ``` + +The first approach is recommended because it integrates better with `trpc_agent` multi-agent conversation management. + +## Custom Event Emission + +### LangGraphEventWriter Usage + +Within LangGraph nodes, `LangGraphEventWriter` can be used to emit custom events. These events can be captured by event translators (such as AG-UI's event translator) and converted into protocol-specific events. + +#### Basic Usage + +```python +from langchain_core.runnables import RunnableConfig +from langgraph.types import StreamWriter +from trpc_agent_sdk.agents.utils import LangGraphEventWriter + +def custom_node( + state: State, + *, + config: RunnableConfig, + writer: StreamWriter, +): + """Emit events within a node using LangGraphEventWriter + + Important: config and writer must be keyword-only arguments (after *), + so that LangGraph can inject them correctly. + + Args: + state: Graph state + config: Runtime configuration (keyword argument, injected by LangGraph) + writer: Stream writer (keyword argument, injected by LangGraph) + """ + # Create an event writer from config + event_writer = LangGraphEventWriter.from_config(writer, config) + + # Emit a text event + event_writer.write_text("Processing data...") + + # Emit a custom event (structured data) + event_writer.write_custom({ + "stage": "processing", + "progress": 50, + "status": "in_progress", + }) + + return {} +``` + +#### Text Events + +The `write_text()` method is used to emit text messages: + +```python +# Emit plain text +event_writer.write_text("Processing data...") + +# Emit thought text +event_writer.write_text("Let me analyze this problem...", thought=True) + +# Emit a complete message (non-streaming) +event_writer.write_text("Processing complete!", partial=False) +``` + +Parameter descriptions: +- `text`: Text content +- `partial`: Whether this is a streaming/partial event (default `True`) +- `thought`: Whether this is thought/reasoning text (default `False`) + +#### Custom Events + +The `write_custom()` method is used to emit structured data: + +```python +# Emit progress information +event_writer.write_custom({ + "stage": "initialization", + "progress": 0, + "status": "starting", +}) + +# Emit arbitrary structured data +event_writer.write_custom({ + "metric": "accuracy", + "value": 0.95, + "timestamp": time.time(), +}) +``` + +### Building AG-UI Protocol Messages + +To convert LangGraph events into AG-UI protocol messages, you need to create a custom event translator. + +#### Creating a Custom Translator + +```python +from typing import AsyncGenerator +from ag_ui.core import BaseEvent, EventType, CustomEvent +from trpc_agent_sdk.events import Event as TrpcEvent +from trpc_agent_sdk.agents.utils import LangGraphEventType, get_event_type +from trpc_agent_sdk.server.ag_ui import ( + AgUiLangGraphEventTranslator, + AgUiTranslationContext, +) + +class CustomAgUiEventTranslator(AgUiLangGraphEventTranslator): + """Custom AG-UI event translator""" + + async def translate( + self, + event: TrpcEvent, + context: AgUiTranslationContext, + ) -> AsyncGenerator[BaseEvent, None]: + """Convert a LangGraph trpc Event to AG-UI events + + Args: + event: trpc Event object + context: AG-UI translation context (contains thread_id and run_id) + + Yields: + AG-UI BaseEvent objects + """ + # Get the event type (using enum) + event_type = get_event_type(event) + + if event_type == LangGraphEventType.TEXT: + # Handle text events + yield await self._translate_text_event(event, context) + elif event_type == LangGraphEventType.CUSTOM: + # Handle custom events + yield await self._translate_custom_event(event, context) + + async def _translate_text_event( + self, + event: TrpcEvent, + context: AgUiTranslationContext, + ) -> CustomEvent: + """Convert a text event to an AG-UI CustomEvent""" + # Extract text content + text_content = "" + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + text_content += part.text + + # Return an AG-UI CustomEvent + return CustomEvent( + type=EventType.CUSTOM, + timestamp=int(event.timestamp * 1000), # Convert to milliseconds + raw_event=None, + name="progress_text", # Custom event name + value={"text": text_content}, + ) + + async def _translate_custom_event( + self, + event: TrpcEvent, + context: AgUiTranslationContext, + ) -> CustomEvent: + """Convert a custom event to an AG-UI CustomEvent""" + # Extract custom data + custom_data = {} + if event.custom_metadata: + custom_data = event.custom_metadata.get("data", {}) + + # Return an AG-UI CustomEvent + return CustomEvent( + type=EventType.CUSTOM, + timestamp=int(event.timestamp * 1000), + raw_event=None, + name="analysis_progress", # Custom event name + value=custom_data, # Pass custom data + ) +``` + +#### Using the Custom Translator + +Inject the custom translator when creating an `AgUiAgent`: + +```python +from trpc_agent_sdk.server.ag_ui import AgUiAgent + +def create_agui_agent() -> AgUiAgent: + """Create an AgUiAgent with a custom event translator""" + from agent.event_translator import CustomAgUiEventTranslator + + # Create the custom translator instance + custom_translator = CustomAgUiEventTranslator() + + agui_agent = AgUiAgent( + trpc_agent=your_langgraph_agent, + app_name="your_app", + event_translator=custom_translator, # Inject the custom translator + ) + return agui_agent +``` + +## Complete Examples + +For complete LangGraph Agent examples, see: +- Basic example: [examples/langgraph_agent](../../../examples/langgraph_agent/README.md) +- AG-UI custom event example: examples/trpc_agui_with_langgraph_custom (example to be added) diff --git a/docs/mkdocs/en/llm_agent.md b/docs/mkdocs/en/llm_agent.md new file mode 100644 index 000000000..bcfff1892 --- /dev/null +++ b/docs/mkdocs/en/llm_agent.md @@ -0,0 +1,899 @@ +# LLM Agent + +`LlmAgent` is a general-purpose AI Agent implementation that uses an LLM as its reasoning core, interacts with external systems through tool calls, and automates complex task workflows. + +Unlike Agents that follow fixed workflows, `LlmAgent` dynamically interprets instructions and context through the LLM, then decides execution steps, tool calls, or delegation to other Agents. For example, in a RAG scenario, the typical flow is "retrieve first, then answer"; however, `LlmAgent` may determine that the question is unrelated to the knowledge base and respond directly without running the full RAG pipeline. + +To create an `LlmAgent`, you need to configure the Agent's basic information and tools. + +## Configuring Agent Basic Information + +In `trpc-agent`, an Agent is identified by the following properties: +- `name` (required): Agent name used for unique identification; +- `description` (optional): Agent description used in multi-Agent scenarios so other Agents can understand its role; +- `model` (required): Agent reasoning core. Different scenarios (conversation, code generation, complex problem solving, etc.) may require different models; + +```python +LlmAgent( + name="weather_agent", + description="A helpful assistant for weather queries", + model="deepseek-chat", + instruction="...", # Will be introduced in the next section +) +``` + +Before running the examples, you need to set the following environment variables (or configure them via a `.env` file): + +```bash +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +For more model configurations supported by tRPC-Agent and how to instantiate and pass parameters for different models, please refer to the [Model Invocation](./model.md) documentation. + +## Configuring Agent Instructions (instruction) + +The `instruction` parameter is the most critical configuration for shaping the behavior of an `LlmAgent`. It is a string (or a function that returns a string) used to tell the Agent: + +* Its core task or objective +* Its personality or role definition (e.g., "You are a friendly assistant", "You are a professional technical consultant") +* Behavioral constraints (e.g., "Only answer questions about X", "Never reveal Y") +* How and when to use its tools. You should explain the purpose of each tool and under what circumstances they should be called +* Expected output format (e.g., "Reply in JSON format", "Provide a bulleted list") + +**Tips for effective instructions:** + +* **Be clear and specific**: Avoid ambiguity. Clearly state the expected actions and outcomes +* **Use Markdown**: Improve readability of complex instructions with headings, lists, etc. +* **Provide examples (Few-Shot)**: For complex tasks or specific output formats, include examples directly in the instructions +* **Guide tool usage**: Don't just list tools; explain _when_ and _why_ the Agent should use them + +**State variables (placeholder variables)**: + +State variables `{var}` can be used in `instruction` to inject session state. + +* The instruction string is a template where you can use `{var}` syntax to insert dynamic values, injecting session state +* `{var}` inserts the value of the corresponding state variable from session state; if the variable does not exist, `trpc_agent` ignores it +* `{var?}` is an optional placeholder; if not present, it is replaced with an empty string + +```python +# Example: Adding instructions +LlmAgent( + name="weather_agent", + description="A professional weather query assistant that can provide real-time weather and forecast information.", + model="deepseek-chat", + # Using state variables for template substitution - demonstrating {var} syntax + instruction=""" + You are a professional weather query assistant, providing services for {user_name}. + + **Current User Information:** + - Username: {user_name} + - City: {user_city} + + **Your Tasks:** + - Understand the user's weather query requirements + - Use appropriate tools to obtain weather information + - Provide clear, useful weather information and suggestions + + **Available Tools:** + 1. `get_weather`: Get current weather information + 2. `get_weather_forecast`: Get multi-day weather forecast + + **Tool Usage Guide:** + - When the user asks about current weather, use `get_weather` + - When the user asks about weather for the coming days, use `get_weather_forecast` + - If the query is ambiguous, you may use both tools simultaneously + + **Response Format:** + - Provide accurate weather information + - Give reasonable travel or clothing suggestions based on weather conditions + - Maintain a friendly, professional tone + - If the user does not specify a city, prioritize querying weather for {user_city} + + **Restrictions:** + - Only answer weather-related questions + - If asked about other topics, politely redirect to weather-related topics + """, +) + # tools will be added in the next section +``` + +`LlmAgent` can also be configured with `output_key` to store the Agent's output in session state for template usage (typically in cross-Agent interaction scenarios), as shown below: + +```python +LlmAgent( + name="weather_agent", + description="A helpful assistant for weather queries", + model="deepseek-chat", + instruction="...", + output_key="weather_info", +) +``` + +## Configuring Agent Tools (tools) + +Tools are how an Agent interacts with the external world. They can be API calls, database queries, file operations, or any operation that can be represented as a Python function. The framework currently supports multiple tool types: + +- Function: Local function calls, supporting function parameters (string, integer, float, list, dict, boolean, pydantic.BaseModel) +- AgentTool: Allows wrapping an Agent as a Tool, enabling the output of one Agent to be used as the input of another Agent +- McpTool: A mechanism for integrating external MCP server tools. Through the MCP protocol, an Agent can invoke tools provided by other processes + +For more tool types, see [Tools](./tool.md). + +```python +from trpc_agent_sdk.tools import FunctionTool + +# Define the weather retrieval tool function +def get_weather_report(city: str) -> dict: + """Get weather information for the specified city""" + # Simulate weather API call + weather_data = { + "北京": { + "temperature": "25°C", + "condition": "Sunny", + "humidity": "60%" + }, + "上海": { + "temperature": "28°C", + "condition": "Cloudy", + "humidity": "70%" + }, + "广州": { + "temperature": "32°C", + "condition": "Thunderstorm", + "humidity": "85%" + }, + } + return weather_data.get(city, {"temperature": "Unknown", "condition": "Data not available", "humidity": "Unknown"}) + + +def get_weather_forecast(city: str, days: int = 3) -> list: + """Get multi-day weather forecast for the specified city""" + # Simulate forecast data + return [ + { + "date": "2024-01-01", + "temperature": "25°C", + "condition": "Sunny" + }, + { + "date": "2024-01-02", + "temperature": "23°C", + "condition": "Cloudy" + }, + { + "date": "2024-01-03", + "temperature": "20°C", + "condition": "Light rain" + }, + ][:days] + + +def create_agent(): + """Create a weather query Agent to demonstrate various LLM Agent capabilities.""" + + # Create tools + weather_tool = FunctionTool(get_weather_report) + forecast_tool = FunctionTool(get_weather_forecast) + + return LlmAgent( + name="weather_agent", + description="A professional weather query assistant that can provide real-time weather and forecast information.", + model="deepseek-chat", + instruction=INSTRUCTION, # INSTRUCTION is the same as in the previous section + tools=[weather_tool, forecast_tool], + # Configure generation parameters + generate_content_config=GenerateContentConfig( + temperature=0.3, # Reduce randomness for more deterministic responses + top_p=0.9, + max_output_tokens=1500, + ), + # Enable Planner to enhance reasoning capabilities (commented out by default). Uncomment the following line to give the model reasoning capabilities, allowing it to reason before generating responses + # planner=PlanReActPlanner(), + ) + +``` + +**Complete example:** +- [examples/llmagent/run_agent.py](../../../examples/llmagent/run_agent.py) - Basic weather query Agent example + +## Session Management + +The current LLM Agent can manage the visibility of messages generated by other Agents and historical session messages based on different scenarios when needed. This can be configured through related options to control what content is passed to the model during interaction. Below are some session management strategies: + +### Using Preset Session Management Strategies + +LlmAgent provides multiple parameters to control the visibility of session history, helping you optimize Agent context management across different scenarios: + +- `max_history_messages` and `message_timeline_filter_mode` are used to control the Agent's visibility of the complete session history +- `message_branch_filter_mode` is used in multi-Agent scenarios to control one Agent's visibility of messages from other Agents + +#### max_history_messages + +The `max_history_messages` parameter limits the number of historical messages passed to the model, helping control token usage in long conversation scenarios: + +```python +from trpc_agent_sdk.agents import LlmAgent + +agent = LlmAgent( + name="history_demo", + description="Agent demonstrating history control", + ..., + max_history_messages=max_history_messages, # Retain only the last max_history_messages messages after filtering +) +``` + +**Parameter description:** +- `max_history_messages=0` (default): No limit on the number of historical messages, includes all filtered messages +- `max_history_messages=N` (N > 0): Only includes the most recent N history messages (applied after other filters) + +**Use cases:** +- Controlling token usage in long conversation scenarios +- Scenarios where the Agent needs to focus only on recent conversation content +- Preventing performance issues caused by overly long context + +**Notes:** +- This strategy is applied **after** `message_timeline_filter_mode` and `message_branch_filter_mode` filtering +- Only keeps the most recent N messages + +#### message_timeline_filter_mode + +The `message_timeline_filter_mode` parameter controls the visibility of historical messages across multiple conversation rounds: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents import TimelineFilterMode + +agent = LlmAgent( + name="timeline_demo", + description="Agent demonstrating timeline filtering", + ..., + message_timeline_filter_mode=timeline_mode, +) +``` + +**Available values:** +- `TimelineFilterMode.ALL` (default): Includes messages from all conversation rounds +- `TimelineFilterMode.INVOCATION`: Only includes messages generated during the current invocation (`runner.run_async()`) + +**Use cases:** +- `ALL`: When the Agent needs to remember the complete conversation history +- `INVOCATION`: When the Agent needs to only process the current request, ignoring historical context + +#### message_branch_filter_mode + +In multi-Agent scenarios, the `message_branch_filter_mode` parameter controls the current Agent's visibility of messages from other Agents. Each Agent has a unique branch identifier during execution (e.g., `CustomerService.TechnicalSupport.DatabaseExpert`). Through branch filtering, you can precisely control the visible scope of messages. For example, consider the following four Agents: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents import BranchFilterMode + +# Database expert Agent - handles database-related issues +database_expert = LlmAgent( + name="DatabaseExpert", + description="Database expert, diagnoses and resolves database issues", + instruction="You are a database expert, specializing in database performance and troubleshooting", + message_branch_filter_mode=BranchFilterMode.PREFIX, # Only sees related hierarchy +) + +# Technical support Agent - handles technical issues, can call database expert +technical_support = LlmAgent( + name="TechnicalSupport", + instruction="You are a technical support specialist, handling technical issues", + message_branch_filter_mode=BranchFilterMode.PREFIX, # Only sees related hierarchy + sub_agents=[database_expert], +) + +# Billing support Agent - fully isolated, only sees its own messages +billing_support = LlmAgent( + name="BillingSupport", + instruction="You are a billing support specialist, handling billing issues", + message_branch_filter_mode=BranchFilterMode.EXACT, # Fully isolated +) + +# Customer service Agent - does not need to be aware of other Agents' history, only cares about where the current request is dispatched +customer_service = LlmAgent( + name="CustomerService", + instruction="You are a customer service coordinator, routing user requests to the appropriate department", + message_branch_filter_mode=BranchFilterMode.EXACT, # Fully isolated + sub_agents=[technical_support, billing_support], +) +``` + +**Available values:** + +1. **`BranchFilterMode.ALL` (default)**: Includes messages from all Agents + - Use case: When the Agent needs to interact with the model and synchronize all valid content messages generated by all Agents + - Example: Scenarios requiring cross-department information sharing + +2. **`BranchFilterMode.PREFIX`**: Prefix matching, includes messages from related hierarchy levels + - Use case: When you want to pass messages generated by the current Agent and related upstream/downstream Agents + - Example: The technical support Agent (branch: `CustomerService.TechnicalSupport`) can see: + - Messages from parent Agent `CustomerService` + - Its own `TechnicalSupport` messages + - Messages from child Agent `DatabaseExpert` + - But **cannot** see messages from sibling Agent `BillingSupport` + +3. **`BranchFilterMode.EXACT`**: Exact matching, only includes messages from the current Agent + - Use case: When the Agent needs to interact with the model but only uses its own generated messages, achieving full isolation + - Example: The customer service coordinator only needs to see forwarded messages, not messages from other Agents + +**Complete examples:** +- [examples/llmagent_with_max_history_messages/run_agent.py](../../../examples/llmagent_with_max_history_messages/run_agent.py) - max_history_messages example +- [examples/llmagent_with_timeline_filtering/run_agent.py](../../../examples/llmagent_with_timeline_filtering/run_agent.py) - message_timeline_filter_mode example +- [examples/llmagent_with_branch_filtering/run_agent.py](../../../examples/llmagent_with_branch_filtering/run_agent.py) - message_branch_filter_mode example + +### Setting Historical Session Content + +Users may want to set historical session content into the agent service, as follows: + +Construct user history records: +```python +from trpc_agent_sdk.sessions import HistoryRecord + +def make_user_history_record() -> HistoryRecord: + """Construct user history records, simulating the user's previous conversation history""" + record: dict[str, str] = { + "What's your name?": "My name is Alice", + "what is the weather like in paris?": "The weather in Paris is sunny ...", + "Do you remember my name?": "It seems I don't have your name stored ...", + } + + history_record = HistoryRecord() + for query, answer in record.items(): + history_record.add_record(query, answer) + return history_record +``` + +Write prompts to instruct the Agent to prioritize finding answers from historical sessions: +```python +INSTRUCTION = """You are a Q&A assistant. +**Your Tasks:** +- Understand the question and provide a friendly answer +- If relevant data can be found in the historical session, prioritize searching from the historical session to reduce LLM tool calls; if not found in the historical session, then query using tools +""" +``` + +Inject historical records along with the user's query at runtime: +```python +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.types import Content, Part + +for query in demo_queries: + # Get the history record object and build matching context content based on the current query + history_record = make_user_history_record() + history_content = history_record.build_content(query) + user_content = Content(parts=[Part.from_text(text=query)]) + + # Enable session history saving so that multi-turn conversations can accumulate context + run_config = RunConfig(save_history_enabled=True) + # new_message takes a [history_content, user_content] list, + # injecting both historical records and the user's current query into the Agent's input + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=[history_content, user_content], + run_config=run_config, + ): + ... +``` + +**Complete example:** +- [examples/llmagent_with_user_history/run_agent.py](../../../examples/llmagent_with_user_history/run_agent.py) - Setting historical session content example + +## Advanced Configuration and Control + +### GenerateContentConfig + +Used to adjust LLM generation behavior, such as temperature, top-p, and other parameters: + +```python +from trpc_agent_sdk.types import GenerateContentConfig + +weather_agent = LlmAgent( + name="weather_agent", + model="deepseek-chat", + instruction="...", + tools=[weather_tool], + generate_content_config=GenerateContentConfig( + temperature=0.1, # Reduce randomness for more deterministic responses + top_p=0.95, + max_output_tokens=1000, + ) +) +``` + +### ToolPrompt + +Sometimes, the LLM model service does not support FunctionCall capabilities, such as in fine-tuned model scenarios. To enable LLMs that do not support FunctionCall to have this capability, the framework supports injecting tool definitions into the system_prompt via `ToolPrompt`, then parsing specific text from the LLM output to implement tool calling. + +The usage is straightforward. As shown below, you only need to add the `add_tools_to_prompt` option to `OpenAIModel` to enable this feature. + +```python +OpenAIModel( + model_name="deepseek-chat", + base_url="https://api.deepseek.com/v1", + api_key=os.environ.get("API_KEY", ""), + add_tools_to_prompt=True, + # The framework provides two ways to inject tool_prompt: "xml" and "json". If tool_prompt is not specified, "xml" is used by default + # tool_prompt="xml", +), +``` + +Note that the framework provides `tool_prompt` to let users choose the format for converting tool definitions to text. It provides xml and json conversion formats by default, and you can switch between them by passing different strings. + +In addition to accepting strings, `tool_prompt` can also accept a class that inherits from `ToolPrompt`, providing extensibility for custom tool conversion text. As shown below, this will use the framework-provided `XmlToolPrompt`: + +```python +from trpc_agent_sdk.models.tool_prompt import XmlToolPrompt +OpenAIModel( + model_name="deepseek-chat", + base_url="https://api.deepseek.com/v1", + api_key=os.environ.get("API_KEY", ""), + add_tools_to_prompt=True, + # Note: pass the type rather than an instance, because streaming parsing is chunk-based and stateful + tool_prompt=XmlToolPrompt, +), +``` + +Users can implement a custom `CustomToolPrompt` for tool-to-text conversion. The purpose of each interface is shown below. + +For implementation details, refer to [XmlToolPrompt](../../../trpc_agent_sdk/models/tool_prompt/_xml.py). + +```python +from trpc_agent_sdk.models.tool_prompt import ToolPrompt + +class CustomToolPrompt(ToolPrompt): + @override + def build_prompt(self, tools: List[Tool]) -> str: + """Build a prompt string from a list of tools. + + Args: + tools: List of Tool objects to convert to prompt text + + Returns: + String representation of tools for inclusion in system prompt + """ + pass + + @override + def parse_function(self, content: str) -> List[FunctionCall]: + """Parse function calls from complete content. + + Args: + content: Complete content string containing function calls + + Returns: + List of FunctionCall objects parsed from content + + Raises: + ValueError: If content cannot be parsed as function calls + """ + pass +``` + +**Complete example:** +- [examples/llmagent_with_tool_prompt/run_agent.py](../../../examples/llmagent_with_tool_prompt/run_agent.py) - ToolPrompt usage example + +### PlanReActPlanner + +Planner customizes the Agent's planning process. It essentially intercepts the LLM's input and output content. In the input, the Planner can inject planning-related information; in the output, the Planner can process the planning results, such as converting tool call text in the LLM output into trpc_agent's tool structures, enabling tool calls even on models that do not support them. + +The framework provides PlanReActPlanner, which injects Reasoning instructions into the LLM input, enabling models that do not support Reasoning to also have this capability. + +```python +from trpc_agent_sdk.planners import PlanReActPlanner + +weather_agent = LlmAgent( + name="weather_agent", + model="deepseek-chat", + instruction="...", + planner=PlanReActPlanner(), # Enable planning capability +) +``` + +### Enabling Thinking Mode + +Many models currently support thinking mode. The framework controls thinking behavior through `BuiltInPlanner` and `ThinkingConfig`. Thinking mode allows the model to perform internal reasoning before generating the final response, improving answer quality. + +Using thinking mode requires configuring the following components: + +- `BuiltInPlanner`: Built-in planner that supports thinking functionality +- `ThinkingConfig`: Thinking configuration that controls the parameters of thinking behavior + - `include_thoughts`: Whether to include the thinking process in the output, defaults to False + - `thinking_budget`: Token budget for the thinking process, must be less than `max_output_tokens` + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import LLMModel, OpenAIModel +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.planners import BuiltInPlanner +from trpc_agent_sdk.types import ThinkingConfig + + +def _create_model() -> LLMModel: + """Create the model""" + api_key, url, model_name = get_model_config() + model = OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=url, + # There are two scenarios where enabling add_tools_to_prompt can improve Agent generation: + # 1. When the thinking model does not support tool calls, + # you can enable the ToolPrompt framework to parse tool calls from LLM-generated text. + # 2. When the thinking model calls tools during reasoning, + # if the LLM model service cannot return tool calls in JSON format, you can also enable ToolPrompt. + # This prompts the LLM model to output special tool call text in the body, + # increasing the probability of successful tool calls. + # You can uncomment the line below to use ToolPrompt. + # add_tools_to_prompt=True, + ) + return model + + +def create_agent(): + """Create a weather query Agent demonstrating thinking mode usage.""" + + return LlmAgent( + name="weather_agent", + description="A professional weather query assistant that can provide real-time weather and forecast information.", + model=_create_model(), + instruction=INSTRUCTION, + # Note: thinking_budget must be less than max_output_tokens + generate_content_config=GenerateContentConfig(max_output_tokens=10240, ), + # The model must be a thinking model for this Planner to take effect; this configuration will have no effect for non-thinking models. + planner=BuiltInPlanner(thinking_config=ThinkingConfig( + include_thoughts=True, + thinking_budget=2048, + ), ), + ) + +root_agent = create_agent() +``` + +Notes: + +- `thinking_budget`: Must be less than `max_output_tokens` in `generate_content_config` +- `include_thoughts`: When set to True, users can see the model's thinking process; when set to False, only the final result is displayed +- Only models that support thinking can use this feature. Currently supported models include: deepseek-reasoner, glm-4.5-fp8, etc. +- For models that inherently have thinking capabilities (such as qwen3-next-80b-a3b-thinking), this configuration can also be used to control thinking behavior + +**Complete example:** +- [examples/llmagent_with_thinking/run_agent.py](../../../examples/llmagent_with_thinking/run_agent.py) - Agent example with thinking mode enabled + +### Model Creation Callback Function + +In some scenarios, you may need to dynamically create model instances at runtime rather than fixing the model when initializing the Agent. For example: +- Dynamically adjusting model parameters based on runtime configuration +- Using different API keys or base URLs for different requests + +The framework supports passing an async function as a model creation callback to `LlmAgent`. This callback function receives data from `RunConfig.custom_data` and returns a model instance. + +***In addition to LlmAgent, both ClaudeAgent and TeamAgent support this usage.*** + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel, LLMModel +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.runners import Runner + +async def create_model(custom_data: dict) -> LLMModel: + """Model creation callback function + + Args: + custom_data: Data passed from RunConfig.custom_data + + Returns: + LLMModel instance + """ + print(f"📦 Model creation function received custom_data: {custom_data}") + + return OpenAIModel( + model_name=model_name, + base_url="https://api.openai.com/v1", + api_key=os.environ.get("OPENAI_API_KEY", ""), + ) + +# Pass the model creation callback when creating the Agent +weather_agent = LlmAgent( + ..., + model=create_model, # Pass the model creation callback +) + +# Create Runner +runner = Runner( + app_name="weather_app", + agent=weather_agent, + session_service=session_service +) + +# Pass custom_data through RunConfig +run_config = RunConfig(custom_data={"user_tier": "premium"}) + +async for event in runner.run_async( + ... + run_config=run_config +): + # Process Agent output... + pass +``` + +**Complete example:** +- [examples/llmagent_with_model_create_fn/run_agent.py](../../../examples/llmagent_with_model_create_fn/run_agent.py) - Model creation callback usage example + +### Structured Input and Output + +#### output_schema Usage + +LlmAgent supports configuring structured output (output_schema). By configuring `output_schema`, you can specify the Agent's output format. It is generally necessary to specify the output structure format in the instruction. + +The implementation mechanism of output_schema has two different methods depending on whether tools are used: + +1. When tools are not configured for LlmAgent, it uses the LLM's [Structured Output](https://platform.openai.com/docs/guides/structured-outputs) capability (requires model service support). When the LLM supports response_format as json_schema (e.g., GPT series), there is no need to write the output format in the instruction, as the framework will auto-populate it. However, when the LLM only supports response_format as json_object (e.g., DeepSeek), users need to specify the JSON output format in the instruction. +2. When tools are configured, the LLM's Structured Output capability is not used (Structured Output cannot be used with Tools). The framework will inject a set_model_response tool into the Agent to trigger the LLM to call this tool to set the JSON output. + +```python +from pydantic import BaseModel +from typing import List + +from trpc_agent_sdk.agents import LlmAgent + +class UserProfileOutput(BaseModel): + """Output schema for user profile analysis.""" + user_name: str + age_group: str # "young", "adult", "senior" + personality_traits: List[str] + recommended_activities: List[str] + profile_score: int # 1-10 + summary: str + +profile_agent = LlmAgent( + name="user_profile_agent", + model="deepseek-chat", + instruction="...", + output_schema=UserProfileOutput, + output_key="user_profile", +) + +# After runner execution, obtain the Agent's structured data through state +async for event in runner.run_async(...): + pass + +session = await session_service.get_session(xxx) +user_profile_json = session.state[profile_agent.output_key] +user_profile = UserProfileOutput.model_validate_json(user_profile_json) +print(user_profile) +``` + +#### input_schema Usage + +LlmAgent also supports structured input (input_schema), which is generally used in conjunction with [AgentTool](./tool.md). AgentTool automatically validates whether the Agent's input/output conforms to the schema, as shown below: + +```python +from pydantic import BaseModel +from typing import List, Optional + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import AgentTool + +class UserProfileInput(BaseModel): + """Input schema for user profile creation.""" + + name: str + age: int + email: str + interests: List[str] + location: Optional[str] = None + +profile_agent = LlmAgent( + ..., + input_schema=UserProfileInput, + output_schema=UserProfileOutput, +) + +profile_tool = AgentTool( + agent=profile_agent, + skip_summarization=True, +) + +main_agent = LlmAgent( + name="main_processor", + description="Main processing Agent that can call the user profile analysis tool", + model="deepseek-chat", + instruction="...", + tools=[profile_tool], +) +``` + +**Complete example:** +- [examples/llmagent_with_schema/run_agent.py](../../../examples/llmagent_with_schema/run_agent.py) - Structured input/output Agent example + +### Setting Parallel Tool Calls + +When the Agent calls multiple tools that involve significant network latency, concurrent invocation can be used: + +```python +def create_agent(): + """Create an Agent configured with hobby-related tools""" + # ... + return LlmAgent( + name="hobby_toolset_agent", + description="An assistant demonstrating hobby ToolSet usage", + model=model, + tools=[hobby_toolset], + parallel_tool_calls=True, + instruction=""" +You are a virtual person who loves life. Select appropriate tools based on user interests to retrieve hobby information and provide friendly responses. +**Your Tasks:** +- If the conversation contains content related to running or sports, you must call the sports tool. If no sports parameter is provided, default to running +- If the conversation contains content related to TV, you must call the watch_tv tool. If no TV parameter is provided, default to cctv +- If the conversation contains content related to music, you must call the listen_music tool. If no music parameter is provided, default to QQ Music +""", + ) +``` + +Enable concurrent tool calls by setting the `parallel_tool_calls` field to True in `LlmAgent`. + +**Complete example:** +- [examples/llmagent_with_parallal_tools/run_agent.py](../../../examples/llmagent_with_parallal_tools/run_agent.py) - Parallel tool calls example + +### Disabling Framework Auto-injected Prompts + +In some scenarios, you may want to fully control the prompt content passed to the LLM without the framework automatically injecting additional information. trpc-agent provides two configuration options to disable the framework's auto-injection behavior: + +#### add_name_to_instruction + +By default, the framework automatically injects the Agent's name information in multiple places: + +1. **Injecting name in instruction**: Format is `"You are an agent who's name is [agent_name]."` +2. **In multi-Agent collaboration scenarios**: When one Agent's output is passed to another Agent, a `"[agent_name]: content"` prefix is added + +You can disable these behaviors by setting `add_name_to_instruction` to `False`: + +```python +COORDINATOR_INSTRUCTION = """You are a customer service coordinator. +Route customer requests to the appropriate department: +- Weather questions -> WeatherAssistant +- Translation requests -> TranslationAssistant +Be concise and professional.""" + +coordinator = LlmAgent( + name="Coordinator", + description="Customer service coordinator", + ..., + instruction=COORDINATOR_INSTRUCTION, + add_name_to_instruction=False, # Disable auto-injection, "You are an agent who's name is [Coordinator]." will not be added to the instruction +) +``` + +#### default_transfer_message + +In multi-Agent scenarios, when sub_agents are configured for an Agent, the framework automatically injects prompts related to child Agents. By setting `default_transfer_message`, you can override the default prompt injected by the framework: + +```python +CUSTOM_TRANSFER_MESSAGE = """When you need help from other agents: +- Call the transfer_to_agent tool +- Choose the most suitable agent based on the user's question +Available agents: +- WeatherAssistant: handles weather queries +- TranslationAssistant: handles translation requests +""" + +coordinator = LlmAgent( + name="Coordinator", + ..., + description="Customer service coordinator", + instruction=COORDINATOR_INSTRUCTION, + default_transfer_message=CUSTOM_TRANSFER_MESSAGE, # Use custom transfer prompt +) +``` + +**Note: To successfully delegate to child Agents, mention the `transfer_to_agent` tool explicitly in the prompt. Delegation completes only when the Agent invokes this tool; the framework injects it automatically when `sub_agents` is configured.** + +This parameter has the following configurations: +- None (default): The framework will enable auto-injection +- Empty string: Will disable framework auto-injection +- Custom: Will use the user-defined string to replace the framework's auto-injected information + +Complete example: [examples/llmagent_with_custom_prompt/run_agent.py](../../../examples/llmagent_with_custom_prompt/run_agent.py). + +### Starting Next Conversation Round from the Last Responding Agent + +By default, each round of a multi-turn conversation starts from the entry Agent. However, some scenarios require starting from the last responding Agent. For example, after delegating to an Agent, you may want to continue the conversation with that Agent for several rounds. The framework provides a `RunConfig` option to support this capability: + +```python +from trpc_agent_sdk.configs import RunConfig + +run_config = RunConfig(start_from_last_agent=True) +async for event in runner.run_async(...,run_config=run_config): + ... +``` + +Complete example: [examples/multi_agent_start_from_last/run_agent.py](../../../examples/multi_agent_start_from_last/run_agent.py). + +## Core Concepts + +### InvocationContext + +`InvocationContext` represents the context of a single Agent invocation, containing the services, session, user input, run configuration, and state information required for execution. + +Typically, you do not need to manually create an `InvocationContext`. It is recommended to use `Runner.run_async()` directly, letting the framework automatically handle the preparation of `session`, `invocation_id`, `branch`, `run_config`, and other context. Only when you need full control over the execution flow should you construct an `InvocationContext` directly and call `agent.run_async(ctx)`. + +Common use cases: + +- Need full control over the invocation context +- Need to manually construct or reuse a `session` +- Testing, custom framework integration, low-level debugging scenarios + +Commonly used fields in `InvocationContext`: + +- `session_service`: Responsible for managing session read/write, persistence, and context assembly associated with the current invocation +- `artifact_service`: Used for saving and reading attachments, files, and other artifacts; unavailable when not configured +- `memory_service`: Used for storing and retrieving long-term memory and historical information; unavailable when not configured +- `invocation_id`: A unique identifier assigned to each invocation, facilitating tracing and troubleshooting +- `branch`: Used for isolating visible history in multi-Agent or sub-Agent scenarios, preventing parallel branches from polluting each other's context +- `agent`: Represents the Agent instance that is currently executing logic +- `agent_context`: Carries user interaction control-related context, such as interaction strategies or runtime control information +- `user_content`: Represents the user input content that triggered this invocation +- `session`: Carries the current session itself and its state data +- `end_invocation`: Can be set to `True` during callbacks or tool execution to terminate the current invocation early +- `run_config`: Stores the run configuration used for this execution, such as model, streaming output, or other runtime parameters + +#### Invocation State + +`InvocationContext` provides two commonly used state access methods: + +- `ctx.state`: Writable state with a dictionary-style interface that automatically records incremental changes from the current invocation +- `ctx.session_state`: Read-only view for safely reading the current session state + +Additional notes: + +- `ctx.state`: Internally reuses the current `session.state`, but simultaneously records modifications to `event_actions.state_delta`, enabling the framework to detect and commit incremental changes +- `ctx.session_state`: Returns a read-only mapping view, suitable for use when only reading state is needed, avoiding accidental modifications to shared state + +### Event + +`Event` is the unified event object produced during Agent execution, and is the content unit continuously yielded by `Runner.run_async()` and `agent.run_async()`. `Event` extends `LlmResponse`, supplementing the response content with invocation chain and event metadata. + +Commonly used fields include: + +- `content`: The actual content payload of the event, which can be text or structured parts including tool calls, tool results, etc. +- `partial`: Commonly seen during streaming output; when `True`, it usually indicates the current event is not yet a complete result +- `error_code` / `error_message`: Used to identify whether the event is in an error state, along with the accompanying error description +- `invocation_id`: Used to associate the event back to its invocation chain; multiple events produced during a single invocation typically share the same ID +- `author`: Identifies who wrote the event, commonly used to distinguish between user messages, Agent output, and other participants +- `actions`: Carries action information associated with this event, such as state deltas, artifact changes, or other execution side effects +- `branch`: Used for multi-Agent branch isolation, helping the framework determine history visibility between different sub-chains +- `id` / `timestamp`: Used for uniquely identifying the event and recording when the event was produced, facilitating sorting, tracing, and debugging +- `visible`: When set to `False`, the event can still exist in internal flows, but `Runner` may choose not to expose it to external callers + +`Event` also provides convenient methods, such as: + +- `is_final_response()`: Not only checks for the absence of tool calls and tool returns, but also considers `partial`, long-running tool calls, and other conditions to comprehensively determine whether the current event can be considered a final response +- `get_function_calls()`: Extracts all `function_call` from `content.parts` for unified tool request handling +- `get_function_responses()`: Extracts all `function_response` from `content.parts` for reading tool execution results +- `get_text()`: Concatenates all text parts in the event; returns an empty string if there is no text content + +### AgentABC + +`Agent` is the abstract base class for all Agents, defining basic Agent properties, sub-Agent management capabilities, and the async execution entry point. + +Core capabilities include: + +- `name` / `description`: The Agent's name and capability description +- `sub_agents`: List of child Agents +- `find_agent()` / `find_sub_agent()`: Find Agents by name within the Agent tree +- `run_async(parent_context)`: The Agent's underlying async execution entry point + +For business code, it is still generally recommended to use `Runner.run_async()` as the unified entry point; `AgentABC.run_async()` is more suitable for advanced encapsulation, custom Agent implementations, or testing scenarios. +## Other Agent Types + +Now that you understand the LLM Agent provided in trpc_agent, click the links below to learn about other Agent types and how to use them: + +- **[LangGraph Agent](./langgraph_agent.md)**: Learn how to use Graph to customize controllable workflows for Agents +- **[Multi Agents](./multi_agents.md)**: Master the usage and best practices of Chain, Parallel, Cycle, and Sub Agents +- **[Custom Agent](./custom_agent.md)**: Learn how to implement fully custom Agent/Multi-Agent logic + +Choose the Agent type that best fits your needs and start building powerful AI applications! diff --git a/docs/mkdocs/en/memory.md b/docs/mkdocs/en/memory.md new file mode 100644 index 000000000..aab852406 --- /dev/null +++ b/docs/mkdocs/en/memory.md @@ -0,0 +1,1814 @@ +# Memory Service Documentation + +## Overview + +`MemoryService` is a core component in trpc-agent for managing **long-term memory**. Unlike `SessionService` which manages the context of the current session, `MemoryService` focuses on storing and retrieving historical memories across sessions, helping the Agent recall relevant content in subsequent conversations. + +### Memory vs Session + +| Feature | Session | Memory | +|-----|---------|--------| +| **Scope** | Single session | Cross-session (shared across all sessions) | +| **Lifecycle** | Created and destroyed with the session | Independent of sessions, controlled by TTL | +| **Stored Content** | Complete conversation history of the current session | Key events and knowledge fragments | +| **Access Method** | Automatically loaded into context | Retrieved via `load_memory` tool | +| **Typical Use** | Context for a single conversation | Long-term memory, user profiles, knowledge accumulation | + +--- + +## Core Capabilities of MemoryService + +Based on the implementation in [trpc_agent_sdk/memory/](../../../trpc_agent_sdk/memory/), MemoryService provides the following core capabilities: + +### 1. Storing Session Memory + +**Function**: Stores key events from a Session as long-term memory. + +**Implementation Methods**: +- **InMemoryMemoryService**: Stored in an in-process memory dictionary +- **RedisMemoryService**: Stored in a Redis List (JSON format) +- **SqlMemoryService**: Stored in the `mem_events` table in MySQL/PostgreSQL +- **MempalaceMemoryService**: Stored as MemPalace drawers in a local ChromaDB-backed palace + +**Code Example**: +```python +# Store session to Memory +await memory_service.store_session(session=session) +``` + +**Storage Logic** (using `InMemoryMemoryService` as an example): +```python +# from trpc_agent_sdk/memory/_in_memory_memory_service.py +async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + # Data structure: {save_key: {session_id: [EventTtl, ...]}} + self._session_events[session.save_key] = self._session_events.get(session.save_key, {}) + self._session_events[session.save_key][session.id] = [ + EventTtl(event=event, ttl=self._memory_service_config.ttl) + for event in session.events + if event.content and event.content.parts # Only store events with content + ] +``` + +--- + +### 2. Searching Related Memories + +**Function**: Searches for related historical memories based on query keywords. + +**Search Method**: Built-in InMemory/Redis/SQL services use **keyword matching**; semantic memory services such as MemPalace and Mem0 use vector / semantic retrieval. + +**Implementation Logic** (using `InMemoryMemoryService` as an example): +```python +# From trpc_agent_sdk/memory/_in_memory_memory_service.py +async def search_memory(self, key: str, query: str, limit: int = 10, ...) -> SearchMemoryResponse: + # 1. Extract query keywords (supports both Chinese and English) + words_in_query = extract_words_lower(query) # Extract English words and Chinese characters + + # 2. Iterate over all session events + for session_events in self._session_events[key].values(): + for event_ttl in session_events: + # 3. Extract keywords from the event + words_in_event = extract_words_lower(' '.join([part.text for part in event.content.parts if part.text])) + + # 4. Keyword matching (return on any query word match) + if any(query_word in words_in_event for query_word in words_in_query): + response.memories.append(MemoryEntry(...)) + # 5. Refresh TTL (refresh expiration time on access) + event_ttl.update_expired_at() +``` + +**Keyword Extraction** ([_utils.py](../../../trpc_agent_sdk/memory/_utils.py)): +```python +def extract_words_lower(text: str) -> set[str]: + """Extract English words and Chinese characters""" + words = set() + # Extract English words (letter sequences) + words.update([word.lower() for word in re.findall(r'[A-Za-z]+', text)]) + # Extract Chinese characters (Unicode range \u4e00-\u9fff) + words.update(re.findall(r'[\u4e00-\u9fff]', text)) + return words +``` + +**Usage Example**: +```python +from trpc_agent_sdk.types import SearchMemoryResponse + +# Search related memories +search_key = f"{app_name}/{user_id}" # Format: app_name/user_id +response: SearchMemoryResponse = await memory_service.search_memory( + key=search_key, + query="weather", # Query keyword + limit=10 # Return at most 10 memories +) + +# Process search results +for memory in response.memories: + print(f"Memory content: {memory.content}") + print(f"Author: {memory.author}") + print(f"Timestamp: {memory.timestamp}") +``` + +--- + +### 3. TTL (Time-To-Live) Cache Eviction + +**Function**: Automatically cleans up expired memory data, preventing unlimited memory/storage growth. + +**Implementation Methods**: +- **InMemoryMemoryService**: Background periodic cleanup task (`_cleanup_loop`) +- **RedisMemoryService**: Redis native `EXPIRE` mechanism (automatic expiration) +- **SqlMemoryService**: Background periodic cleanup task (batch SQL DELETE) +- **MempalaceMemoryService**: Background periodic cleanup task (batch drawer deletion by metadata timestamp) + +**TTL Configuration**: +```python +from trpc_agent_sdk.memory import MemoryServiceConfig + +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, # Enable TTL + ttl_seconds=86400, # Memory expiration time: 24 hours + cleanup_interval_seconds=3600, # Cleanup interval: 1 hour (InMemory/SQL only) + ), +) +``` + +**TTL Refresh Mechanism**: +- **Refresh on access**: TTL is refreshed for matched events during `search_memory` +- **Refresh on storage**: TTL is set for new events during `store_session` +- **Persistent semantic services**: Some services, such as MemPalace, delete expired drawers by stored event timestamp rather than refreshing TTL on every search. + +--- + +### 4. Cross-Session Sharing + +**Function**: Different sessions can share the same memory data. + +**Implementation Method**: +- Uses `save_key` (format: `app_name/user_id`) as the memory key +- All sessions from the same user share the same memory space +- Uses `key=f"{app_name}/{user_id}"` during search to retrieve all memories for that user + +**Data Structure** (InMemoryMemoryService): +```python +# Data structure: {save_key: {session_id: [EventTtl, ...]}} +_session_events = { + "weather_app/user_001": { + "session_1": [EventTtl(...), EventTtl(...)], + "session_2": [EventTtl(...), EventTtl(...)], + }, + "weather_app/user_002": { + "session_3": [EventTtl(...)], + } +} +``` + +--- + +## MemoryService Implementations + +trpc-agent provides multiple `MemoryService` implementations, allowing you to choose the appropriate storage backend based on your scenario: + +### InMemoryMemoryService + +**How It Works**: Stores memory data directly in the application's memory. + +**Implementation Details** (based on `_in_memory_memory_service.py`): +- **Data Structure**: `dict[str, dict[str, list[EventTtl]]]` (nested dictionaries) +- **Storage Location**: Process memory +- **Search Method**: Keyword matching (iterating over in-memory dictionary) +- **TTL Mechanism**: Background periodic cleanup task (`_cleanup_loop`) +- **Cleanup Method**: Two-phase deletion (collect expired items → batch delete) + +**Persistence**: ❌ **None**. All memory data is lost if the application restarts. + +**Applicable Scenarios**: +- ✅ Rapid development +- ✅ Local testing +- ✅ Demo examples +- ✅ Scenarios that do not require long-term persistence + +**Configuration Example**: +```python +from trpc_agent_sdk.memory import InMemoryMemoryService, MemoryServiceConfig + +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # 24-hour expiration + cleanup_interval_seconds=3600, # Cleanup every 1 hour + ), +) + +memory_service = InMemoryMemoryService(memory_service_config=memory_service_config) +``` + +**Notes**: +- When `enabled=True`, MemoryService automatically stores Session events, **no need to manually call `store_session`** +- If `enabled=False`, MemoryService will not store any data +- The cleanup task runs in the background, periodically deleting expired events + +**Related Examples**: +- 📁 [`examples/memory_service_with_in_memory/run_agent.py`](../../../examples/memory_service_with_in_memory/run_agent.py) - Complete In-Memory Memory Service usage example + +--- + +### RedisMemoryService + +**How It Works**: Uses Redis to store memory data, supporting multi-node sharing. + +**Implementation Details** (based on `_redis_memory_service.py`): +- **Data Structure**: Redis List (`RPUSH` to store event JSON) +- **Storage Location**: Redis external storage +- **Key Format**: `memory:{save_key}:{session_id}` +- **Search Method**: `KEYS memory:{key}:*` + keyword matching +- **TTL Mechanism**: Redis native `EXPIRE` command (automatic expiration) +- **TTL Refresh**: Automatically refreshed on access (during `search_memory`) + +**Persistence**: ✅ **Yes**. Data is persisted in Redis and can be recovered after application restart. + +**Applicable Scenarios**: +- ✅ Production environments +- ✅ Multi-node deployments +- ✅ High-performance caching requirements +- ✅ Distributed applications + +**Configuration Example**: +```python +import os +from trpc_agent_sdk.memory import RedisMemoryService, MemoryServiceConfig + +# Read Redis configuration from environment variables +db_host = os.environ.get("REDIS_HOST", "127.0.0.1") +db_port = os.environ.get("REDIS_PORT", "6379") +db_password = os.environ.get("REDIS_PASSWORD", "") +db_db = os.environ.get("REDIS_DB", 0) + +# Build Redis connection URL +if db_password: + db_url = f"redis://:{db_password}@{db_host}:{db_port}/{db_db}" +else: + db_url = f"redis://{db_host}:{db_port}/{db_db}" + +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # 24-hour expiration (automatically handled by Redis) + ), +) + +memory_service = RedisMemoryService( + db_url=db_url, + is_async=True, # Use async mode (recommended) + memory_service_config=memory_service_config, + enabled=True, +) +``` + +**Redis Data Structure**: +```bash +# Storage format: Redis List +memory:weather_app/user_001:session_1 + └─ [0] '{"id":"event_1","author":"user","content":{...},"timestamp":...}' + └─ [1] '{"id":"event_2","author":"assistant","content":{...},"timestamp":...}' + +# TTL setting +EXPIRE memory:weather_app/user_001:session_1 86400 # Expires after 24 hours +``` + +**Notes**: +- When `is_async=True`, the async Redis client is used, which is friendly for concurrent scenarios +- When `is_async=False`, the synchronous Redis client is used +- Redis's `EXPIRE` mechanism automatically handles expired keys, **no background cleanup task required** +- The `cleanup_interval_seconds` parameter has no effect on RedisMemoryService (Redis handles expiration automatically) + +**Related Examples**: +- 📁 [`examples/memory_service_with_redis/run_agent.py`](../../../examples/memory_service_with_redis/run_agent.py) - Complete Redis Memory Service usage example + +--- + +### SqlMemoryService + +**How It Works**: Stores memory data in a relational database (MySQL/PostgreSQL). + +**Implementation Details** (based on `_sql_memory_service.py`): +- **Data Structure**: SQL table `mem_events` +- **Storage Location**: MySQL/PostgreSQL database +- **Search Method**: SQL `SELECT` + keyword matching +- **TTL Mechanism**: Background periodic cleanup task (batch SQL DELETE) +- **Cleanup Method**: Single SQL DELETE for batch deletion of expired events + +**Persistence**: ✅ **Yes**. Data is persisted in the database and can be recovered after application restart. + +**Applicable Scenarios**: +- ✅ Production environments +- ✅ Transaction safety requirements +- ✅ Complex query and statistical analysis requirements +- ✅ Data persistence and backup requirements + +**Configuration Example**: +```python +import os +from trpc_agent_sdk.memory import SqlMemoryService, MemoryServiceConfig + +# Read MySQL configuration from environment variables +db_user = os.environ.get("MYSQL_USER", "root") +db_password = os.environ.get("MYSQL_PASSWORD", "") +db_host = os.environ.get("MYSQL_HOST", "127.0.0.1") +db_port = os.environ.get("MYSQL_PORT", "3306") +db_name = os.environ.get("MYSQL_DB", "trpc_agent_memory") + +# Build database connection URL +# Synchronous operation (pymysql) +db_url = f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + +# Asynchronous operation (aiomysql) +# db_url = f"mysql+aiomysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # 24-hour expiration + cleanup_interval_seconds=3600, # Cleanup every 1 hour + ), +) + +memory_service = SqlMemoryService( + db_url=db_url, + is_async=True, # Use async mode (recommended) + memory_service_config=memory_service_config, + enabled=True, + pool_pre_ping=True, # Connection health check (recommended) + pool_recycle=3600, # Connection recycle time: 1 hour +) +``` + +**Database Table Structure**: +```sql +CREATE TABLE mem_events ( + id VARCHAR(255) NOT NULL, -- Event UUID + save_key VARCHAR(255) NOT NULL, -- app_name/user_id + session_id VARCHAR(255) NOT NULL, -- Session ID + invocation_id VARCHAR(255), -- Invocation ID + author VARCHAR(255), -- Author (user/assistant) + content JSON, -- Event content (JSON) + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Creation time + -- ... other fields + PRIMARY KEY (id, save_key, session_id), + INDEX idx_save_key (save_key), -- For retrieval + INDEX idx_timestamp (timestamp) -- For cleanup task +); +``` + +**Cleanup Task** (batch deletion): +```python +# From _sql_memory_service.py +async def _cleanup_expired_async(self) -> None: + """Batch delete expired events""" + expire_before = datetime.now() - timedelta(seconds=self._memory_service_config.ttl.ttl_seconds) + + # Single SQL DELETE for batch deletion + DELETE FROM mem_events + WHERE timestamp < expire_before; +``` + +**Notes**: +- When `is_async=True`, the `aiomysql` driver is used; requires installation: `pip install aiomysql` +- When `is_async=False`, the `pymysql` driver is used; requires installation: `pip install pymysql` +- `pool_pre_ping=True` is recommended to avoid stale connections +- `pool_recycle=3600` sets connection recycle time to avoid long-lived connections +- The cleanup task uses batch SQL DELETE for performance optimization + +**Related Examples**: +- 📁 [`examples/memory_service_with_sql/run_agent.py`](../../../examples/memory_service_with_sql/run_agent.py) - Complete SQL Memory Service usage example + +--- + +## Comparison of Three Implementations + +| Feature | InMemoryMemoryService | RedisMemoryService | SqlMemoryService | +|-----|----------------------|-------------------|------------------| +| **Data Storage** | Process memory | Redis external storage | MySQL/PostgreSQL | +| **Persistence** | ❌ Lost on process restart | ✅ Persisted in Redis | ✅ Persisted in database | +| **Distributed** | ❌ Cannot share across processes | ✅ Supports cross-process/server | ✅ Supports cross-process/server | +| **TTL Mechanism** | ✅ Periodic cleanup task | ✅ **Redis automatic expiration** | ✅ **Periodic cleanup task (batch)** | +| **Cleanup Efficiency** | ⭐⭐⭐ Requires scanning | ⭐⭐⭐⭐⭐ Redis native | ⭐⭐⭐⭐ **Single SQL batch delete** | +| **Transaction Support** | ❌ | ❌ | ✅ **ACID transactions** | +| **Complex Queries** | ❌ | ❌ | ✅ **SQL queries** | +| **Deployment Scenarios** | Local development/single node | Production/distributed/caching | Production/distributed/relational data | +| **Performance** | ⭐⭐⭐⭐⭐ Extremely fast | ⭐⭐⭐⭐ Fast | ⭐⭐⭐ Medium | + +**Recommendations**: +- **Development and testing** → `InMemoryMemoryService` (zero dependencies, quick startup) +- **Production (high performance)** → `RedisMemoryService` (Redis automatic expiration, no background tasks) +- **Production (transactions/queries)** → `SqlMemoryService` (transaction safety, supports complex queries) +- **Enterprise (TRPC ecosystem)** → `TrpcRedisMemoryService` (service discovery, monitoring and alerting) + +--- + +## Usage Examples + +### Basic Usage Flow + +```python +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.memory import MemoryServiceConfig +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.types import Content, Part + +# 1. Create MemoryService +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, + cleanup_interval_seconds=3600, + ), +) +memory_service = InMemoryMemoryService(memory_service_config=memory_service_config) + +# 2. Create SessionService +session_service = InMemorySessionService() + +# 3. Create Runner and configure services +runner = Runner( + app_name="my_app", + agent=my_agent, + session_service=session_service, + memory_service=memory_service # Configure MemoryService +) + +# 4. Run Agent (MemoryService will automatically store events) +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_message +): + # Process events... + pass + +# 5. Search related memories (via load_memory tool) +# Agent will automatically call memory_service.search_memory() +``` + +### Manual Storage and Search + +```python +# Manually store session to Memory +session = await session_service.get_session( + app_name="my_app", + user_id=user_id, + session_id=session_id +) +if session: + await memory_service.store_session(session=session) + +# Manually search memories +search_key = f"{app_name}/{user_id}" +response = await memory_service.search_memory( + key=search_key, + query="user's name", + limit=10 +) + +for memory in response.memories: + print(f"Memory: {memory.content}") +``` + +--- + +## Integrating SessionService and MemoryService + +In practical applications, you typically need to use both `SessionService` and `MemoryService` together: + +```python +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.memory import InMemoryMemoryService, MemoryServiceConfig +from trpc_agent_sdk.runners import Runner + +# Create service instances +session_service = InMemorySessionService() +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, + ), +) +memory_service = InMemoryMemoryService(memory_service_config=memory_service_config) + +# Create Runner and configure services +runner = Runner( + app_name="my_app", + agent=my_agent, + session_service=session_service, + memory_service=memory_service # Optional: configure MemoryService +) + +# Run Agent +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_message +): + # Process events... + pass +``` + +**Workflow**: + +1. **SessionService** manages the context of the current session (conversation history, state, etc.) +2. **MemoryService** automatically stores Session events to long-term memory (if `enabled=True`) +3. **load_memory tool** calls `memory_service.search_memory()` to retrieve related memories +4. The Agent can simultaneously access the current session context and historical memories, providing a more coherent conversation experience + +--- + +## Related Examples + +The following examples demonstrate the usage of different MemoryService implementations: + +### InMemoryMemoryService + +📁 **Example Path**: [`examples/memory_service_with_in_memory/run_agent.py`](../../../examples/memory_service_with_in_memory/run_agent.py) + +**Description**: +- Demonstrates basic usage of In-Memory Memory Service +- Shows cross-session memory sharing +- Demonstrates TTL cache eviction mechanism +- Includes detailed analysis of execution results + +**How to Run**: +```bash +cd examples/memory_service_with_in_memory/ +python3 run_agent.py +``` + +--- + +### RedisMemoryService + +📁 **Example Path**: [`examples/memory_service_with_redis/run_agent.py`](../../../examples/memory_service_with_redis/run_agent.py) + +**Description**: +- Demonstrates Redis Memory Service usage +- Shows Redis automatic expiration mechanism +- Provides detailed Redis operations guide +- Includes execution result analysis and Redis command examples + +**How to Run**: +```bash +cd examples/memory_service_with_redis/ +python3 run_agent.py +``` + +--- + +### SqlMemoryService + +📁 **Example Path**: [`examples/memory_service_with_sql/run_agent.py`](../../../examples/memory_service_with_sql/run_agent.py) + +**Description**: +- Demonstrates SQL Memory Service usage +- Shows MySQL table structure and data operations +- Demonstrates batch cleanup task +- Provides MySQL operation commands and execution result analysis + +**How to Run**: +```bash +cd examples/memory_service_with_sql/ +python3 run_agent.py +``` + +--- + +## Integrating Mem0 + +### What is Mem0? + +Mem0 is an intelligent, self-improving memory layer for LLMs that can persist and retrieve user information across conversations, enabling more personalized and coherent user experiences. + +**Core Capabilities:** +- 🧠 Intelligent memory extraction and storage +- 🔍 Semantic search of historical conversations +- 🔄 Automatic memory updates and deduplication +- 🎯 User-level memory isolation + +**Official Resources:** +- Official documentation: [https://docs.mem0.ai/introduction](https://docs.mem0.ai/introduction) +- GitHub: [https://github.com/mem0ai/mem0](https://github.com/mem0ai/mem0) + +--- + +### tRPC-Agent Integration Methods + +tRPC-Agent provides two methods for integrating Mem0: + +| Method | Class / Tool | Applicable Scenario | +|---|---|---| +| **Framework-level memory service** (recommended) | `Mem0MemoryService` | The framework automatically handles cross-session memory storage and retrieval, transparent to the Agent | +| **Tool-based memory** | `SearchMemoryTool` / `SaveMemoryTool` | The Agent actively calls Mem0 through tools, with flexible control over storage and retrieval timing | + +--- + +### Mem0MemoryService (Recommended) + +`Mem0MemoryService` is tRPC-Agent's **framework-level memory service**. The framework automatically calls `store_session` after each turn of the conversation completes to store session memories. The Agent actively retrieves related memories through the `load_memory` tool when generating a response, without manual management of storage and retrieval timing. + +#### Core Design + +- **Two-level key strategy**: `session.save_key` → Mem0 `user_id` (user dimension); `session.id` → `run_id` (session dimension) +- **Cross-session sharing**: Different sessions from the same user share the same memory +- **TTL automatic expiration**: Background periodic cleanup of expired memories + +#### Quick Integration + +**Step 1: Create `Mem0MemoryService`** + +```python +from mem0 import AsyncMemory, AsyncMemoryClient +from trpc_agent_sdk.memory import MemoryServiceConfig +from trpc_agent_sdk.memory.mem0_memory_service import Mem0MemoryService + +# Self-hosted mode (AsyncMemory + Qdrant) +from mem0.configs.base import MemoryConfig +mem0_client = AsyncMemory(config=MemoryConfig(**{ + "vector_store": {"provider": "qdrant", "config": {"host": "localhost", "port": 6333}}, # Vector database declaration + "llm": {"provider": "deepseek", "config": {"model": "...", "api_key": "..."}}, # Used for memory summarization (used when infer=True) + "embedder": {"provider": "huggingface", "config": {"model": "multi-qa-MiniLM-L6-cos-v1"}}, # Open-source embedding model +})) + +# Or: Remote platform mode (AsyncMemoryClient), no self-hosted infrastructure needed +mem0_client = AsyncMemoryClient(api_key="your_mem0_api_key", host="https://api.mem0.ai") + +memory_service = Mem0MemoryService( + mem0_client=mem0_client, + memory_service_config=MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config(enable=False), # Disable TTL, memories are retained permanently + ), + infer=False, # False=store raw content (stable), True=semantic extraction (intelligent) +) +``` + +**Step 2: Pass `memory_service` to `Runner`** + +```python +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.tools import load_memory_tool + +agent = LlmAgent( + name="assistant", + model=your_model, + tools=[load_memory_tool], # Agent actively retrieves memories through this tool + instruction="Use load_memory to recall relevant past conversations before answering.", +) + +runner = Runner( + app_name="my_app", + agent=agent, + session_service=InMemorySessionService(), + memory_service=memory_service, # Framework handles storage automatically +) +``` + +**Step 3: Run, memories are automatically persisted across sessions** + +```python +# First conversation round (session_1) +async for event in runner.run_async(user_id="alice", session_id="session_1", new_message=...): + ... +# The framework automatically calls store_session after the conversation ends, storing this round's messages into Mem0 + +# Second conversation round (session_2) — new session, but can retrieve memories from session_1 +async for event in runner.run_async(user_id="alice", session_id="session_2", new_message=...): + ... +``` + +#### `infer` Parameter Selection + +| | `infer=False` (recommended) | `infer=True` | +|---|---|---| +| Stored Content | Raw conversation text | Semantic facts extracted by LLM | +| Stability | High, every entry is stored | Medium, not stored when LLM determines NONE | +| Token Consumption | Low (no LLM calls) | High (LLM called on each write) | +| Conflict Resolution | None | Automatic (new facts override old facts) | +| Recommended Scenario | Complete history archival, production environments | Long-term user profiling, preference extraction | + +#### TTL Configuration (Optional) + +```python +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # Memory retention: 24 hours + cleanup_interval_seconds=3600, # Cleanup every 1 hour + ), +) +``` + +> For detailed explanations, execution result analysis, and FAQ: [examples/memory_service_with_mem0/README.md](../../../examples/memory_service_with_mem0/README.md) + +--- + +### Tool-based Integration (mem0_tool) + +tRPC-Agent integrates Mem0 through **Tools**, providing memory capabilities to Agents. The framework provides two core tool classes: + +| Tool Class | Tool Name | Function | Use Case | +|--------|--------|------|---------| +| `SearchMemoryTool` | `search_memory` | Search historical memories | When the Agent needs to recall past conversations | +| `SaveMemoryTool` | `save_memory` | Save important information | When the Agent determines user information should be remembered | + +> **Note**: Both tool classes require a Mem0 client to be passed during instantiation. The `user_id` is automatically injected by the framework through `InvocationContext` and does not need to be explicitly passed as a tool parameter. + +#### Integration Architecture + +``` +┌──────────────────────┐ +│ User Input │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ tRPC-Agent │◄─────────┐ +│ LlmAgent │ │ +└──────────┬───────────┘ │ + │ │ + │ Call tools │ Return memories + │ │ + ▼ │ +┌──────────────────────┐ │ +│ Mem0 Tools │──────────┘ +│ - SearchMemoryTool │ +│ - SaveMemoryTool │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Mem0 Client │ +│ (AsyncMemory / │ +│ AsyncMemoryClient) │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ Storage │ +│ - Qdrant │ +│ - Mem0 Cloud │ +└──────────────────────┘ +``` + +--- + +### Deployment Modes + +tRPC-Agent supports two deployment modes for Mem0: self-hosted mode and platform mode + +#### Mode Comparison + +| Feature | Self-hosted Mode | Platform Mode | +|------|-----------|---------| +| **Client Type** | `AsyncMemory` | `AsyncMemoryClient` | +| **Storage Location** | Local vector database (e.g., Qdrant) | Mem0 Cloud | +| **Dependencies** | Vector database + Embedding model + LLM | API Key only | +| **Data Control** | Full control | Managed service | +| **Applicable Scenario** | Development testing, data-sensitive, local deployment | Production environment, rapid deployment | + +#### Mode 1: Self-hosted (AsyncMemory) + +Suitable for scenarios requiring full control over data and infrastructure. + +**Core Components:** +- **Vector Store**: Supports multiple backends (see the complete list below) +- **LLM**: Used for generating memory summaries (OpenAI / DeepSeek / Gemini, etc.) +- **Embedding Model**: Used for vectorization (HuggingFace / OpenAI, etc.) + +**Complete List of Supported Vector Stores for Self-hosted:** +- `azure_ai_search` +- `azure_mysql` +- `baidu` +- `cassandra` +- `chroma` +- `databricks` +- `elasticsearch` +- `faiss` +- `langchain` +- `milvus` +- `mongodb` +- `neptune_analytics` +- `opensearch` +- `pgvector` +- `pinecone` +- `qdrant` +- `redis` +- `s3_vectors` +- `supabase` +- `turbopuffer` +- `upstash_vector` +- `valkey` +- `vertex_ai_vector_search` +- `weaviate` + +> Official vector store implementation list (refer to the mem0 repository): [mem0/vector_stores](https://github.com/mem0ai/mem0/tree/main/mem0/vector_stores) + +**Example Code:** +```python +from mem0 import AsyncMemory +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool + +# Configure custom components +config = { + "vector_store": {"provider": "qdrant", "config": {...}}, + "llm": {"provider": "deepseek", "config": {...}}, + "embedder": {"provider": "huggingface", "config": {...}} +} + +# Create Mem0 client +memory = await AsyncMemory.from_config(config) + +# Instantiate tools with the client +search_memory_tool = SearchMemoryTool(client=memory) +save_memory_tool = SaveMemoryTool(client=memory) +``` + +**Detailed Configuration:** See [Complete Example - Self-hosted Mode](../../../examples/memory_service_with_mem0/README.md#自托管模式asyncmemory--qdrant) + +#### Mode 2: Platform (AsyncMemoryClient) + +Suitable for rapid deployment and production environment usage. + +**Prerequisites:** +- Register a [Mem0 platform account](https://app.mem0.ai/dashboard) +- Obtain an API Key + +**Example Code:** +```python +from mem0 import AsyncMemoryClient +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool + +# Create platform client +client = AsyncMemoryClient( + api_key="m0-your-api-key", + host="https://api.mem0.ai" +) + +# Instantiate tools with the client +search_memory_tool = SearchMemoryTool(client=client) +save_memory_tool = SaveMemoryTool(client=client) +``` + +**Detailed Configuration:** See [Complete Example - Platform Mode](../../../examples/memory_service_with_mem0/README.md#远端平台模式asyncmemoryclient) + +--- + +### Mem0 Quick Start + +#### 1. Install Dependencies + +```bash +# Install Mem0 core package +pip install mem0ai + +# Additional dependencies for self-hosted mode +pip install sentence-transformers qdrant-client + +# Or install via trpc-agent extension +pip install -e ".[mem0]" +``` + +#### 2. Create Agent + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool, SaveMemoryTool + +# Step 1: Instantiate tools, pass in Mem0 client (choose self-hosted or platform mode) +search_memory_tool = SearchMemoryTool(client=your_mem0_client) +save_memory_tool = SaveMemoryTool(client=your_mem0_client) + +# Step 2: Create Agent with memory tools +agent = LlmAgent( + name="memory_assistant", + description="A personal assistant with memory capabilities", + model=your_model, + instruction=""" + You are a helpful assistant with memory capabilities. + - Use search_memory to recall past conversations + - Use save_memory to store important information + - Always personalize responses based on memory + """, + tools=[search_memory_tool, save_memory_tool], +) +``` + +#### 3. Run Agent + +```python +from trpc_agent_sdk.runners import Runner + +runner = Runner( + app_name="memory_app", + agent=agent, + session_service=your_session_service +) + +# Interact with Agent, memory features are used automatically +async for event in runner.run_async( + user_id="alice", + session_id="session_1", + new_message=user_input +): + # Process response + pass +``` + +**Complete Runnable Example:** [examples/memory_service_with_mem0/run_agent.py](../../../examples/memory_service_with_mem0/run_agent.py) + +--- + +### Tool API + +#### SearchMemoryTool + +Search the user's historical memories. + +**Constructor:** +```python +SearchMemoryTool( + client: Union[AsyncMemoryClient, AsyncMemory], + filters_name: str | None = None, # Optional: filter name passed through to BaseTool + filters: dict | None = None, # Optional: filter conditions passed through to BaseTool + **kwargs, # Optional: additional parameters passed through to client.search() (e.g., limit) +) +``` + +**Agent Tool Parameters (callable by LLM):** +- `query` (string, required): Search query content (natural language) + +> `user_id` is automatically injected by the framework from `InvocationContext` and does not need to be passed as a tool parameter. + +**Return Value:** +```python +# Memories found successfully +{ + "status": "success", + "memories": "- Memory content 1\n- Memory content 2", + "user_id": "alice" +} + +# No memories found +{ + "status": "no_memories", + "message": "No relevant memories found" +} +``` + +#### SaveMemoryTool + +Save important information to user memory. + +**Constructor:** +```python +SaveMemoryTool( + client: Union[AsyncMemoryClient, AsyncMemory], + filters_name: str | None = None, # Optional: filter name passed through to BaseTool + filters: dict | None = None, # Optional: filter conditions passed through to BaseTool + infer: bool = True, # Optional: whether to enable LLM semantic extraction (default True) + **kwargs, # Optional: additional parameters passed through to client.add() +) +``` + +> When `infer=True`, Mem0 calls the LLM for semantic extraction before storage; when `infer=False`, the raw content is stored directly. + +**Agent Tool Parameters (callable by LLM):** +- `content` (string, required): Content to save + +> `user_id` is automatically injected by the framework from `InvocationContext` and does not need to be passed as a tool parameter. + +**Return Value:** +```python +# Save successful +{ + "status": "success", + "message": "Information saved to memory", + "result": {...}, + "user_id": "alice" +} + +# Save failed +{ + "status": "error", + "message": "Failed to save memory: error details", + "user_id": "alice" +} +``` + +**Tool Source Code:** [trpc_agent_sdk/tools/mem0_tool.py](../../../trpc_agent_sdk/tools/mem0_tool.py) + +--- + +### Typical Workflow (Tool-based) + +#### Scenario: Personal Assistant Remembering User Preferences + +``` +1. User: Do you remember my name? + ↓ + Agent calls: search_memory(query="user's name") + Framework automatically injects user_id="alice" + ↓ + Result: no_memories + ↓ + Agent: I don't have your name. Could you tell me? + +2. User: My name is Alice + ↓ + Agent calls: save_memory(content="User's name is Alice") + Framework automatically injects user_id="alice" + ↓ + Result: success + ↓ + Agent: Thank you, Alice! I'll remember that. + +3. User: Do you remember my name? + ↓ + Agent calls: search_memory(query="user's name") + Framework automatically injects user_id="alice" + ↓ + Result: success, memories="- Name is Alice" + ↓ + Agent: Yes, your name is Alice! +``` + +**View Complete Demo Output (Mem0MemoryService):** [Execution Result Analysis](../../../examples/memory_service_with_mem0/README.md#运行结果分析) + +--- + +### Advanced Features + +#### Multi-user Memory Isolation + +Memory isolation at the user level is achieved through the `user_id` parameter: + +```python +# User A's memories +await runner.run_async(user_id="user_a", ...) + +# User B's memories (completely independent) +await runner.run_async(user_id="user_b", ...) +``` + +#### Memory Filtering and Search + +The `filters` parameter enables fine-grained memory retrieval, supporting filtering by user, category, and other dimensions to avoid interference from cross-user or irrelevant memories: + +```python +memories = await mem0_client.search( + query="favorite food", # Semantic search query (Mem0 vectorizes and matches) + filters={ + "user_id": "alice", # Restrict to user scope, ensuring memory isolation + "category": "preferences", # Custom category tag, narrowing search scope + }, + limit=5, # Return at most 5 most relevant memories +) +``` + +#### Direct Memory Management + +In addition to indirect operations through Agent tools, you can also directly call the Mem0 client API to manage memories (add, delete, query): + +```python +# Get all memories for a specific user +all_memories = await memory.get_all(user_id="alice") + +# Delete a single memory by memory_id +await memory.delete(memory_id="memory-id") + +# Clear all memories for the user +await memory.delete_all(user_id="alice") +``` + +**More Advanced Usage:** [Advanced Usage Documentation](../../../examples/mem0_tools/README.md#高级用法) + +--- + +### Mem0 FAQ + +#### How to Choose a Deployment Mode? + +| Consideration | Self-hosted | Platform | +|---------|-------|------| +| High data privacy requirements | ✅ | ❌ | +| Quick startup | ❌ | ✅ | +| Need custom embedding models | ✅ | ❌ | +| Production high availability | ❌ | ✅ | +| Cost-sensitive (small scale) | ✅ | ❌ | + +#### Common Errors in Self-hosted Mode + +**Vector Dimension Mismatch:** +``` +Vector dimension error: expected dim: 1536, got 384 +``` +**Cause:** Embedding model dimensions do not match the vector database collection. +**Solution:** Ensure the embedding model and vector database collection have matching dimensions (e.g., `multi-qa-MiniLM-L6-cos-v1` outputs 384 dimensions). + +**Cannot Connect to Qdrant:** +``` +ConnectionError: Cannot connect to Qdrant at localhost:6333 +``` +**Solution:** Confirm Qdrant is running (`docker run -p 6333:6333 qdrant/qdrant`). + +**More Issues:** [Mem0MemoryService FAQ](../../../examples/memory_service_with_mem0/README.md#常见问题-qa) + +--- + +### Mem0 References + +#### Framework Resources + +| Resource | Path | Description | +|---|---|---| +| `Mem0MemoryService` complete example | [examples/memory_service_with_mem0/](../../../examples/memory_service_with_mem0/README.md) | Includes execution result analysis, FAQ | +| `Mem0MemoryService` source code | [mem0_memory_service.py](../../../trpc_agent_sdk/memory/mem0_memory_service.py) | Service implementation | +| Tool-based integration source code | [mem0_tools.py](../../../trpc_agent_sdk/tools/mem0_tools.py) | `SearchMemoryTool` / `SaveMemoryTool` tool classes | +| infer parameter details | [README.md#infer-参数详解](../../../examples/memory_service_with_mem0/README.md#infer-参数详解) | True vs False comparison | +| FAQ | [README.md#常见问题-qa](../../../examples/memory_service_with_mem0/README.md#常见问题-qa) | Error analysis and answers | + +#### Mem0 Official Resources +- **Official Documentation:** [https://docs.mem0.ai/introduction](https://docs.mem0.ai/introduction) +- **GitHub:** [https://github.com/mem0ai/mem0](https://github.com/mem0ai/mem0) +- **Example Code:** [https://github.com/mem0ai/mem0/tree/main/examples](https://github.com/mem0ai/mem0/tree/main/examples) +- **Platform Console:** [https://app.mem0.ai/dashboard](https://app.mem0.ai/dashboard) + +--- + +### Next Steps + +1. **Quick Start (recommended):** Check out the [Mem0MemoryService Complete Example](../../../examples/memory_service_with_mem0/) and run `run_agent.py` +2. **Choose Deployment Mode:** Refer to the [Self-hosted vs Remote Platform Comparison](../../../examples/memory_service_with_mem0/README.md#两种部署模式详解) +3. **Understand infer Differences:** Refer to [infer Parameter Details](../../../examples/memory_service_with_mem0/README.md#infer-参数详解) to choose the appropriate configuration +4. **Platform Deployment:** Register on the [Mem0 Platform](https://app.mem0.ai/dashboard) and obtain an API Key +5. **Custom Development:** Extend custom logic based on the [Mem0MemoryService Source Code](../../../trpc_agent_sdk/memory/mem0_memory_service.py) + +--- + +## Integrating MemPalace + +### What is MemPalace? + +MemPalace is a local-first memory system for storing verbatim memories and retrieving historical context with semantic search. Its core storage hierarchy can be understood as: + +```text +Palace + └── Wing + └── Room + └── Drawer +``` + +In `MempalaceMemoryService`, each storable framework event is filed as a drawer. The drawer contains the original text and metadata such as `wing`, `room`, `session_id`, `event_id`, `author`, and `timestamp`. + +**Core Capabilities:** +- Local persistent storage in a MemPalace palace directory +- Semantic search through MemPalace / ChromaDB +- `wing` and `room` filters for memory isolation +- CLI inspection through `mempalace search` +- TTL cleanup managed by the framework memory service + +--- + +### tRPC-Agent Integration Methods + +The recommended integration path is the framework-level memory service: + +| Method | Class / Tool | Applicable Scenario | +|---|---|---| +| **Framework-level memory service** (recommended) | `MempalaceMemoryService` | The framework automatically writes cross-session memories; the Agent retrieves them through `load_memory` | +| **MemPalace tools** | `mempalace_search` / `mempalace_add_drawer`, etc. | The Agent needs direct access to MemPalace drawers, diary, KG, or other advanced capabilities | + +`MempalaceMemoryService` is the standard MemoryService integration for this project. The framework calls `store_session()` automatically after each turn to persist memory, while the Agent calls `load_memory` during response generation to retrieve historical memories through `search_memory()`. + +--- + +### MempalaceMemoryService (Recommended) + +`MempalaceMemoryService` is a framework-level memory service. The framework stores session memories automatically after each turn, while the Agent retrieves related memories through the built-in `load_memory` tool. + +**How It Works**: Stores memory data as MemPalace drawers in a local-first memory palace backed by ChromaDB. + +**Implementation Details** (based on `mempalace_memory_service.py`): +- **Data Structure**: MemPalace `Palace -> Wing -> Room -> Drawer` +- **Storage Location**: Local MemPalace palace directory, usually `~/.mempalace/palace` +- **Search Method**: MemPalace hybrid semantic search (`search_memories`) with `wing` / `room` filters +- **TTL Mechanism**: Background periodic cleanup task; expired drawers are deleted by metadata timestamp +- **Write Mode**: Incremental background writes; events already scheduled or stored in the current process are skipped +- **Cross-session sharing**: `session.save_key`, usually `{app_name}/{user_id}`, is used as the cross-session memory dimension + +**Persistence**: ✅ **Yes**. Data is persisted in the MemPalace palace directory and can be recovered after application restart. + +**Applicable Scenarios**: +- ✅ Local-first semantic memory +- ✅ Cross-session user profile and preference memory +- ✅ Development or private deployments that should keep memory data on local disk +- ✅ Scenarios where CLI inspection with `mempalace search` is useful + +#### Quick Integration + +**Step 1: Install dependencies** + +```bash +# Install through the trpc-agent extra +pip install -e ".[mempalace]" + +# Or install MemPalace directly +pip install mempalace +``` + +**Step 2: Create `MempalaceMemoryService`** + +```python +from trpc_agent_sdk.memory import MemoryServiceConfig +from trpc_agent_sdk.memory.mempalace_memory_service import MempalaceMemoryService + +memory_service = MempalaceMemoryService( + memory_service_config=MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, + cleanup_interval_seconds=3600, + ), + ), + wing="my_app_user", + room="conversations", +) +``` + +**Step 3: Pass `memory_service` to `Runner`** + +```python +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import load_memory_tool + +agent = LlmAgent( + name="assistant", + model=your_model, + tools=[load_memory_tool], + instruction="Use load_memory to recall relevant past conversations before answering.", +) + +runner = Runner( + app_name="my_app", + agent=agent, + session_service=InMemorySessionService(), + memory_service=memory_service, +) +``` + +**Step 4: Run the Agent; memories are persisted across sessions automatically** + +```python +# First conversation round (session_1) +async for event in runner.run_async(user_id="alice", session_id="session_1", new_message=...): + ... +# After the turn finishes, the framework calls store_session and writes storable events to MemPalace. + +# Second conversation round (session_2) — a new session can still retrieve memories from session_1. +async for event in runner.run_async(user_id="alice", session_id="session_2", new_message=...): + ... +``` + +**Complete Runnable Example:** [examples/memory_service_with_mempalace/run_agent.py](../../../examples/memory_service_with_mempalace/run_agent.py) + +--- + +#### MemPalace Hierarchy Mapping + +```text +session.save_key = "{app_name}/{user_id}" -> wing (when wing is not explicitly configured) +room -> room, defaults to conversations +Event -> drawer +session.id / event.id / author / timestamp -> drawer metadata +``` + +If `wing="trpc-agent"` is configured explicitly, all memories are written into that wing. If `wing` is omitted, the service derives the wing from `save_key`, which is usually the more natural isolation strategy for app/user-scoped long-term memory. + +--- + +#### Path and CLI Search + +MemPalace stores data under `MempalaceConfig().palace_path`. The default path is usually: + +```text +~/.mempalace/palace +``` + +You can configure a custom path through an environment variable: + +```bash +export MEMPALACE_PALACE_PATH=/path/to/palace +``` + +Or through `~/.mempalace/config.json`: + +```json +{ + "palace_path": "/path/to/palace", + "collection_name": "mempalace_drawers" +} +``` + +If the application is configured to use a custom palace path, CLI search must use the same path: + +```bash +mempalace --palace /path/to/palace search "user name" +``` + +Filter by `wing` and `room`: + +```bash +mempalace --palace /path/to/palace search "user name" \ + --wing my_app_user \ + --room conversations +``` + +If no custom path is configured, MemPalace uses its default config, and CLI search can omit `--palace`: + +```bash +mempalace search "user name" --wing my_app_user --room conversations +``` + +> `/path/to/palace` is the MemPalace data directory that contains `chroma.sqlite3`, not a single database file. + +--- + +#### TTL Configuration (Optional) + +```python +memory_service_config = MemoryServiceConfig( + enabled=True, + ttl=MemoryServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # Keep memories for 24 hours + cleanup_interval_seconds=3600, # Run cleanup every hour + ), +) +``` + +Important notes: + +- MemPalace itself does not delete memories automatically just because they have not been used for a long time. +- `MempalaceMemoryService` implements TTL cleanup at the framework layer. +- Cleanup scans drawers written by this service and deletes expired records based on the `timestamp` metadata. +- This TTL policy is based on the original event timestamp; it is not an "extend expiration on access" policy. + +--- + +#### Direct Memory Management + +The service provides a helper to delete all drawers in a wing, or only drawers in a specific room: + +```python +await memory_service.delete_memory(wing="my_app_user") +await memory_service.delete_memory(wing="my_app_user", room="conversations") +``` + +> MemPalace CLI currently does not provide a direct command to delete all memories by `wing` / `room`; use the service helper or call the underlying collection `delete(where=...)`. + +--- + +#### Storage Content Policy + +In general, only ordinary text events with long-term value should be written to MemPalace. Intermediate tool calls, tool responses, and code execution results are usually poor long-term memories because they can cause: + +- `load_memory` results to be written back into memory again +- nested historical memory JSON inside newly stored memories +- tool logs polluting long-term memory and reducing retrieval quality + +`MempalaceMemoryService` is better suited for memories such as: + +```text +User: My name is Alice. +User: My favorite color is blue. +Assistant: Confirmed the user's name or preference. +``` + +Rather than: + +```text +[tool_call] load_memory: ... +[tool_response] load_memory: {"memories": [...]} +``` + +--- + +#### Typical Workflow + +```text +1. User: Do you remember my name? + ↓ + Agent calls: load_memory(query="user name") + ↓ + Result: {"memories": []} + ↓ + Agent: I don't know your name yet. + +2. User: My name is Alice + ↓ + After the turn, the framework automatically calls MempalaceMemoryService.store_session() + ↓ + The user message is written as a drawer under the configured wing/room + +3. User starts a new session: Do you remember my name? + ↓ + Agent calls: load_memory(query="user name") + ↓ + MemPalace returns a historical memory containing "My name is Alice" + ↓ + Agent: Yes, your name is Alice. +``` + +**Complete Demo Output (MempalaceMemoryService):** [examples/memory_service_with_mempalace/README.md](../../../examples/memory_service_with_mempalace/README.md) + +--- + +### Tool-based Integration (mempalace_tool) + +`mempalace_tool` is another way to integrate with MemPalace. It is not the recommended standard MemoryService path. Instead, it exposes MemPalace capabilities as Agent-callable tools, allowing the Agent to decide when to search, write drawers, read or write diary entries, or maintain KG facts. + +The difference from `MempalaceMemoryService` is: + +| Method | Write Timing | Retrieval Method | Applicable Scenario | +|---|---|---|---| +| `MempalaceMemoryService` | The framework writes automatically after each turn | `load_memory` indirectly calls `search_memory()` | Standard cross-session long-term memory | +| `mempalace_tool` | The Agent explicitly calls tools to write | The Agent explicitly calls `mempalace_search` | Fine-grained control over MemPalace drawers, diary, KG, or manual memory management | + +#### Available Tools + +| Tool Class | Tool Name | Function | Use Case | +|---|---|---|---| +| `MempalaceSearchTool` | `mempalace_search` | Semantically search saved drawer content | The Agent needs to recall user profiles, preferences, or historical facts | +| `MempalaceAddDrawerTool` | `mempalace_add_drawer` | Write a verbatim drawer under a specified `wing/room` | The user explicitly asks the Agent to remember long-term information | +| `MempalaceDiaryWriteTool` | `mempalace_diary_write` | Write an agent diary entry | Record runtime observations, task progress, or interim summaries | +| `MempalaceDiaryReadTool` | `mempalace_diary_read` | Read recent diary entries for an agent | The Agent needs to review previous task notes | +| `MempalaceKGAddTool` | `mempalace_kg_add` | Write a knowledge-graph triple fact | Structured facts such as `subject -> predicate -> object` | +| `MempalaceKGQueryTool` | `mempalace_kg_query` | Query relationships for a knowledge-graph entity | Query facts about Alice, project dependencies, or entity relationships | +| `MempalaceKGTimelineTool` | `mempalace_kg_timeline` | Read knowledge-graph facts as a timeline | Inspect how an entity's relationships change over time | +| `MempalaceKGInvalidateTool` | `mempalace_kg_invalidate` | Mark a current fact as no longer valid | Represent fact changes while keeping historical records | + +> **Note**: Like `mem0_tool`, `mempalace_tool` exposes tools to the Agent and lets the model decide when to call them. Unlike Mem0's two search/save tools, MemPalace tools also cover diary and KG operations. See the complete example at [examples/mempalace_tools/README.md](../../../examples/mempalace_tools/README.md), and the tool source at [mempalace_tool.py](../../../trpc_agent_sdk/tools/mempalace_tool.py). + +#### Integration Architecture + +``` +┌──────────────────────┐ +│ User Input │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ tRPC-Agent │◄────────────────┐ +│ LlmAgent │ │ +└──────────┬───────────┘ │ + │ │ returns tool results + │ calls tools │ + ▼ │ +┌──────────────────────┐ │ +│ MemPalace Tools │─────────────────┘ +│ - mempalace_search │ +│ - add_drawer │ +│ - diary read/write │ +│ - KG tools │ +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────┐ +│ MemPalace Backend │ +│ - Palace / ChromaDB │ +│ - KG SQLite │ +└──────────────────────┘ +``` + +#### Quick Integration + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools.mempalace_tool import MempalaceAddDrawerTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceDiaryReadTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceDiaryWriteTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGAddTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGInvalidateTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGQueryTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGTimelineTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceSearchTool + +palace_path = "/tmp/trpc-agent-mempalace-demo" +kg_path = "/tmp/trpc-agent-mempalace-demo/knowledge_graph.sqlite3" + +tools = [ + MempalaceSearchTool(palace_path=palace_path), + MempalaceAddDrawerTool(palace_path=palace_path), + MempalaceDiaryWriteTool(palace_path=palace_path), + MempalaceDiaryReadTool(palace_path=palace_path), + MempalaceKGAddTool(palace_path=palace_path, kg_path=kg_path), + MempalaceKGQueryTool(palace_path=palace_path, kg_path=kg_path), + MempalaceKGTimelineTool(palace_path=palace_path, kg_path=kg_path), + MempalaceKGInvalidateTool(palace_path=palace_path, kg_path=kg_path), +] + +agent = LlmAgent( + name="memory_assistant", + model=your_model, + instruction=""" + You are a helpful assistant with MemPalace tools. + - Use mempalace_search before answering questions that may require past memory. + - Use mempalace_add_drawer when the user explicitly asks you to remember stable facts. + - Use diary tools for agent diary entries. + - Use KG tools for structured facts such as Alice -> likes -> Italian food. + """, + tools=tools, +) +``` + +#### Specify the MemPalace Path + +Tool classes accept `palace_path`. If it is omitted, they use `MempalaceConfig().palace_path`. KG tools also accept `kg_path`; if `kg_path` is omitted and `palace_path` is provided, they default to `palace_path/knowledge_graph.sqlite3`: + +```python +mempalace_search_tool = MempalaceSearchTool(palace_path="/path/to/palace") +mempalace_add_drawer_tool = MempalaceAddDrawerTool(palace_path="/path/to/palace") +mempalace_kg_query_tool = MempalaceKGQueryTool( + palace_path="/path/to/palace", + kg_path="/path/to/palace/knowledge_graph.sqlite3", +) +``` + +Use the same path when inspecting memories from the CLI: + +```bash +mempalace --palace /path/to/palace search "user name" +``` + +The example manages paths through `.env`: + +```bash +MEMPALACE_PALACE_PATH=/tmp/trpc-agent-mempalace-demo +MEMPALACE_KG_PATH=/tmp/trpc-agent-mempalace-demo/knowledge_graph.sqlite3 +MEMPALACE_WING=personal_assistant_alice +MEMPALACE_ROOM=user_profile +``` + +#### Tool-based Workflow + +```text +1. User: Use mempalace_search to check whether you remember my name. + ↓ + Agent calls: mempalace_search( + query="name", + wing="personal_assistant_alice", + room="user_profile" + ) + ↓ + Result: No palace found or empty results + ↓ + Agent: I do not know your name yet. + +2. User: Use mempalace_add_drawer to remember that my name is Alice. + ↓ + Agent calls: mempalace_add_drawer( + wing="personal_assistant_alice", + room="user_profile", + content="User's name is Alice." + ) + ↓ + MemPalace writes the drawer + +3. User starts a new session: Use mempalace_search to recall my name. + ↓ + Agent calls: mempalace_search(query="name", wing="personal_assistant_alice", room="user_profile") + ↓ + MemPalace returns "User's name is Alice." + ↓ + Agent: Your name is Alice. + +4. User: Use mempalace_kg_add to add this fact: Alice likes Italian food. + ↓ + Agent calls: mempalace_kg_add(subject="Alice", predicate="likes", object="Italian food") + ↓ + KG writes the triple fact: Alice -> likes -> Italian food + +5. User: Use mempalace_kg_invalidate to mark the fact Alice likes Italian food as ended today. + ↓ + Agent calls: mempalace_kg_invalidate(subject="Alice", predicate="likes", object="Italian food") + ↓ + KG keeps the historical fact but marks current as false +``` + +**Complete tool-based demo and result analysis:** [examples/mempalace_tools/README.md](../../../examples/mempalace_tools/README.md) + +--- + +#### MempalaceSearchTool + +Semantically searches drawer content saved in MemPalace. + +**Constructor:** +```python +MempalaceSearchTool( + palace_path: str | None = None, + filters_name: list[str] | None = None, + filters: list[Any] | None = None, +) +``` + +**Agent Tool Parameters (callable by LLM):** +- `query` (string, required): Search query +- `limit` (integer, optional): Maximum number of results, defaults to 5 +- `wing` (string, optional): Filter by wing +- `room` (string, optional): Filter by room + +**Return Value Example:** +```python +{ + "query": "name favorite food", + "filters": {"wing": "personal_assistant_alice", "room": "user_profile"}, + "results": [ + {"text": "User's name is Alice.", "wing": "personal_assistant_alice", "room": "user_profile"}, + {"text": "My favorite food is Italian food.", "wing": "personal_assistant_alice", "room": "user_profile"}, + ], +} +``` + +--- + +#### MempalaceAddDrawerTool + +Writes a verbatim drawer under a specified `wing/room`. It is suitable for long-term facts that the user explicitly asks the Agent to remember. + +**Agent Tool Parameters (callable by LLM):** +- `wing` (string, required): Storage scope, for example `personal_assistant_alice` +- `room` (string, required): Memory topic, for example `user_profile` +- `content` (string, required): Verbatim content to save +- `source_file` (string, optional): Source identifier + +**Return Value Example:** +```python +{ + "success": True, + "drawer_id": "drawer_personal_assistant_alice_user_profile_xxx", + "wing": "personal_assistant_alice", + "room": "user_profile", +} +``` + +--- + +#### Diary Tools + +`MempalaceDiaryWriteTool` and `MempalaceDiaryReadTool` record and read agent diary entries. They are useful for "what happened in this task, what was observed, and what to watch next" style runtime notes, and should not replace user-profile memories. + +| Tool | Key Parameters | Return Highlights | +|---|---|---| +| `mempalace_diary_write` | `entry`, `agent_name`, `topic`, `wing` | `success`, `entry_id`, `agent`, `topic` | +| `mempalace_diary_read` | `agent_name`, `last_n`, `wing` | `entries`, `total`, `showing` | + +In the example output, after writing `Alice tested the MemPalace tools example today.`, a later new session can still read that diary entry, showing that diary data is persisted. + +--- + +#### KG Tools + +KG tools maintain structured facts. A fact is usually represented as a triple: + +```text +subject -> predicate -> object +Alice -> likes -> Italian food +``` + +| Tool | Key Parameters | Semantics | +|---|---|---| +| `mempalace_kg_add` | `subject`, `predicate`, `object`, `valid_from`, `valid_to`, `confidence` | Write a structured fact | +| `mempalace_kg_query` | `entity`, `as_of`, `direction` | Query facts related to an entity | +| `mempalace_kg_timeline` | `entity` | Inspect an entity's fact timeline | +| `mempalace_kg_invalidate` | `subject`, `predicate`, `object`, `ended` | Mark a fact as no longer valid | + +`mempalace_kg_invalidate` does not delete the historical fact. It sets `valid_to` and makes `current=False`. The example therefore runs invalidation after the second-phase persistence verification, so it does not alter the query result used to validate persistence. + +#### Recommendations + +- If you only need standard cross-session long-term memory, prefer `MempalaceMemoryService`. +- Use `mempalace_tool` when the Agent needs direct control over what to write, how to classify it, diary operations, or KG maintenance. +- For user profiles and preferences, write to a stable `wing/room`, such as `personal_assistant_alice/user_profile`. +- For KG fact changes, prefer `mempalace_kg_invalidate` to express "no longer true" instead of deleting history. +- Do not let the Agent write `load_memory` tool results, code execution outputs, or other intermediate traces directly into drawers, as this can pollute long-term memory. + +--- + +### MemPalace Resources + +| Resource | Path | Description | +|---|---|---| +| `MempalaceMemoryService` complete example | [examples/memory_service_with_mempalace/](../../../examples/memory_service_with_mempalace/README.md) | Installation, path configuration, CLI search, and execution result analysis | +| `MempalaceMemoryService` source code | [mempalace_memory_service.py](../../../trpc_agent_sdk/memory/mempalace_memory_service.py) | Recommended framework-level memory service implementation | +| MemPalace tools source code | [mempalace_tool.py](../../../trpc_agent_sdk/tools/mempalace_tool.py) | Optional tool-based integration: `mempalace_search`, `mempalace_add_drawer`, diary, KG tools | + +--- + +## Core Feature Summary + +### 1. Cross-session Memory Sharing + +- ✅ Different sessions can access the same memory data +- ✅ Uses `save_key` (`app_name/user_id`) as the memory key +- ✅ Suitable for storing user profiles, long-term preferences, and other cross-session information + +### 2. Keyword or Semantic Search + +- ✅ Supports keyword extraction and matching for both Chinese and English +- ✅ Uses `extract_words_lower` to extract English words and Chinese characters +- ✅ Matching logic: returns on any query word match +- ✅ Semantic memory services such as `MempalaceMemoryService` and `Mem0MemoryService` use vector / semantic retrieval instead of simple keyword matching + +### 3. TTL Cache Eviction + +- ✅ Automatically cleans up expired memories, preventing unlimited storage growth +- ✅ Refreshes TTL on access (during `search_memory`) +- ✅ Different implementations use different cleanup mechanisms +- ⚠️ Some persistent semantic services may use fixed event timestamps for TTL cleanup rather than refreshing TTL on every search + +### 4. Automatic Storage + +- ✅ When `enabled=True`, MemoryService automatically stores Session events +- ✅ No need to manually call `store_session` (unless special control is required) +- ✅ Only stores events with content (`event.content and event.content.parts`) + +### 5. Flexible Storage Backends + +- ✅ Supports multiple implementations: In-Memory, Redis, SQL, Mem0, etc. +- ✅ Supports TRPC Redis integration +- ✅ Supports Mem0 semantic memory integration (vector search + LLM extraction) +- ✅ Supports MemPalace local-first semantic memory integration (ChromaDB-backed palace) +- ✅ Choose the appropriate implementation based on your scenario + +--- + +## Notes + +### 1. enabled Parameter + +- `enabled=True`: MemoryService automatically stores Session events, **no need to manually call `store_session`** +- `enabled=False`: MemoryService does not store any data; both `store_session` and `search_memory` will have no effect + +### 2. Keyword Search Limitations + +- The built-in InMemory/Redis/SQL implementations use **keyword (token) matching**, not semantic search +- After `extract_words_lower` (whole English words, individual Chinese characters), **any** query token that appears in the event's token set counts as a match (this is not full-sentence semantic similarity) +- Suitable for rapid prototyping, not suitable for complex semantic retrieval requirements +- For semantic retrieval, use `MempalaceMemoryService` or `Mem0MemoryService` + +### 3. TTL Configuration + +- `ttl_seconds`: Memory expiration time (in seconds) +- `cleanup_interval_seconds`: Cleanup interval (InMemory/SQL/MemPalace; Redis handles expiration automatically) +- InMemory/Redis refresh TTL on access; persistent semantic services may use stored timestamps for expiration + +### 4. Concurrency Safety + +- `InMemoryMemoryService`: Thread-safe within a single process +- `RedisMemoryService`: Supports multi-process/multi-server concurrency +- `SqlMemoryService`: Supports multi-process/multi-server concurrency (using database transactions) +- `MempalaceMemoryService`: Local-first storage; avoid multiple processes writing to the same palace unless the underlying MemPalace/ChromaDB deployment is managed carefully + +--- + +## Summary + +MemoryService provides powerful long-term memory management capabilities: + +- ✅ **Cross-session sharing**: Different sessions can access shared memories +- ✅ **Automatic storage**: Automatically stores Session events when `enabled=True` +- ✅ **Search**: Supports keyword matching and semantic memory retrieval depending on implementation +- ✅ **TTL eviction**: Automatically cleans up expired memories +- ✅ **Multiple implementations**: In-Memory, Redis, SQL, TRPC Redis, Mem0, MemPalace + +Through proper use of MemoryService, you can achieve: +- User profile construction +- Long-term preference memory +- Cross-session knowledge sharing +- Intelligent conversation context + +For more detailed usage examples, please refer to the related examples in the [examples/](../../../examples/) directory. + +- [examples/memory_service_with_in_memory/run_agent.py](../../../examples/memory_service_with_in_memory/run_agent.py) +- [examples/memory_service_with_redis/run_agent.py](../../../examples/memory_service_with_redis/run_agent.py) +- [examples/memory_service_with_sql/run_agent.py](../../../examples/memory_service_with_sql/run_agent.py) +- [examples/memory_service_with_mem0/run_agent.py](../../../examples/memory_service_with_mem0/run_agent.py) +- [examples/memory_service_with_mempalace/run_agent.py](../../../examples/memory_service_with_mempalace/run_agent.py) diff --git a/docs/mkdocs/en/model.md b/docs/mkdocs/en/model.md new file mode 100644 index 000000000..5b9618e76 --- /dev/null +++ b/docs/mkdocs/en/model.md @@ -0,0 +1,624 @@ +# tRPC-Agent Model Invocation + +## Overview + +tRPC-Agent provides multiple model integration methods, currently supporting the OpenAI protocol (OpenAIModel), Anthropic protocol (AnthropicModel), and LiteLLMModel which unifies multiple providers via LiteLLM. If you have other model integration requirements, feel free to contact us. + +Models in tRPC-Agent have the following core features: + +- **Multi-protocol support**: Provides OpenAIModel, AnthropicModel, LiteLLMModel, etc., compatible with most OpenAI-like and Anthropic interfaces both internally and externally +- **Streaming response support**: Supports streaming output for real-time interactive experiences +- **Multimodal capabilities**: Supports multimodal content processing including text, images, etc. (e.g., Hunyuan multimodal models) +- **Prompt Cache support**: Provides unified prompt cache configuration across OpenAI, Anthropic, and LiteLLM routes to reduce repeated input cost for long prompts and multi-turn conversations +- **Model retry support**: Supports configuring retry at the model layer. The SDK automatically retries when exceptions such as rate limits occur, and backs off using an exponential backoff strategy +- **Extensible configuration**: Supports custom configuration options such as GenerateContentConfig, HttpOptions, client_args to meet various scenario requirements + +## Quick Start + +In tRPC-Agent, a Model serves as the "brain" of an LlmAgent and is passed in via the `model` parameter. After creating a model instance (e.g., `OpenAIModel`, `AnthropicModel`, `LiteLLMModel`), pass it to `LlmAgent` to use the model for conversation and tool invocation. + +### Environment Variable Configuration + +```bash +# API key of the model provider +export TRPC_AGENT_API_KEY="your-api-key" +# Base URL of the model provider (e.g., custom proxy or private deployment address) +export TRPC_AGENT_BASE_URL="your-base-url" +# Model name, e.g., deepseek-chat +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +### Creating a Model and Agent + +```python +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 import FunctionTool + +from .prompts import INSTRUCTION +from .tools import get_weather_report +from .config import get_model_config + + +def _create_model() -> LLMModel: + """ Create a model instance """ + api_key, url, model_name = get_model_config() + model = OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + return model + + +def create_agent() -> LlmAgent: + """ Create an LlmAgent """ + agent = LlmAgent( + name="assistant", # Agent name + description="A helpful assistant for conversation", + model=_create_model(), # Pass in the initialized model + instruction=INSTRUCTION, # System instruction to constrain the model's role and behavior + tools=[FunctionTool(get_weather_report)], # Pass in tools + ) + return agent + + +root_agent = create_agent() +``` + +For a more complete example, see the code repository [Quick Start](../../../examples/quickstart/run_agent.py). + + +The following sections describe usage by protocol type: + +## OpenAIModel + +Most LLMs currently provide an OpenAI-compatible API. Use the `OpenAIModel` class to construct model instances: + +- Obtain the model name, API key, and base URL from various model providers, corresponding to the class parameters `model_name`, `api_key`, and `base_url` respectively +- Model-specific parameters can be configured using `GenerateContentConfig` + +Below are some model providers' base URLs and model names: + +**1. OpenAI Official** + +- Base URL: `https://api.openai.com/v1` +- Model names: `gpt-4o`, `gpt-4o-mini`, etc. + +**2. DeepSeek** + +- Base URL: e.g., `https://api.deepseek.com/v1` +- Model names: `deepseek-chat`, `deepseek-reasoner`, etc. + +**3. Tencent Hunyuan** + +- Base URL: `https://api.hunyuan.cloud.tencent.com/v1` +- Model names: `hunyuan-t1-latest`, `hunyuan-t1-vision-20250619`, etc. + +**4. Other Providers** + +- **Qwen**: Base URL e.g., `https://dashscope.aliyuncs.com/compatible-mode/v1`, model names: various Qwen models + +### Configuration + +#### Environment Variables + +```bash +# API key of the model provider +export TRPC_AGENT_API_KEY="your-api-key" +# Base URL of the model provider (e.g., custom proxy or private deployment address) +export TRPC_AGENT_BASE_URL="your-base-url" +# Model name, e.g., deepseek-chat, gpt-4o +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +#### Code-based Configuration + +```python +from trpc_agent_sdk.models import OpenAIModel + +model = OpenAIModel( + model_name="deepseek-chat", + api_key="your-api-key", + base_url="https://api.deepseek.com/v1", +) +``` + +#### Advanced Usage + +Since version `1.1.10`, `OpenAIModel` supports passing a shared HTTP client provider to enable connection reuse. By default, `OpenAIModel` creates a temporary HTTP client for each model-service request. If you want to reuse connections, use the following configuration: + +```python +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.models import shared_http_client_provider_factory +# from trpc_agent_sdk.models import close_shared_http_clients + +model = OpenAIModel( + model_name="deepseek-chat", + api_key="your-api-key", + base_url="https://api.deepseek.com/v1", + http_client_provider_factory=shared_http_client_provider_factory, +) +# ... + +# If you need to force-close the shared HTTP clients, use: +# await close_shared_http_clients() +``` + +### Integration with Various Platform Model Services: + +#### Hunyuan Model Invocation + +```python +from trpc_agent_sdk.models import OpenAIModel + +LlmAgent( + ..., + model=OpenAIModel( + model_name="hunyuan-t1-latest", + api_key="your-api-key", # Replace with your actual key + base_url="https://api.hunyuan.cloud.tencent.com/v1", + ) +) +``` + +#### Hunyuan Multimodal Model Invocation + +For image modality, please provide the correct `mime_type` (e.g., `image/png`, `image/jpeg`). The framework automatically encodes the raw image bytes to base64 and concatenates them into the `data:{mime_type};base64,{base64_data}` format as the `image_uri`. You only need to pass in the raw image content without manually performing base64 encoding or constructing the `image_uri`. + +```python +LlmAgent( + ..., + model=OpenAIModel( + model_name="hunyuan-t1-vision-20250619", + api_key="your-api-key", # Replace with your actual key + base_url="https://api.hunyuan.cloud.tencent.com/v1", + # Add custom headers + client_args={ "default_headers": {"Accept": "*/*", "Content-Type": "application/json"}}, + ), +) + +query_text = "Please describe this image" + +image_path = "your-image-path" +with open(image_path, "rb") as f: + image_data = f.read() + +user_content = Content( + parts=[ + Part.from_text(text=query_text), + Part.from_bytes(data=image_data, mime_type="image/png") + ] +) + +runner.run_async(xxx, new_message=user_content) +``` + +## AnthropicModel + +AnthropicModel is used to integrate with Anthropic-compatible platforms such as Claude. If you need to directly use external model services like Claude, you can integrate via the Anthropic protocol. The framework already supports this protocol — use the `AnthropicModel` class to construct model instances. + +### Configuration + +#### Environment Variables + +```bash +# API key of the model provider +export TRPC_AGENT_API_KEY="your-api-key" +# Base URL of the model provider (e.g., custom proxy or private deployment address) +export TRPC_AGENT_BASE_URL="your-base-url" +# Model name, e.g., claude-3-5-sonnet-20241022 +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +#### Code-based Configuration + +Using Zhipu AI as an example: + +```python +from trpc_agent_sdk.models import AnthropicModel + +LlmAgent( + ..., + model=AnthropicModel( + model_name=os.environ.get("TRPC_AGENT_MODEL_NAME", "glm-4.6"), + api_key=os.environ.get("TRPC_AGENT_API_KEY", ""), + base_url=os.environ.get("TRPC_AGENT_BASE_URL", "https://open.bigmodel.cn/api/anthropic"), + ), +) +``` + +#### Advanced Usage + +Since version `1.1.10`, `AnthropicModel` supports passing a shared HTTP client provider to enable connection reuse. By default, `AnthropicModel` creates a temporary HTTP client for each model-service request. See the Advanced Usage section under `OpenAIModel` for an example. + +## LiteLLMModel +As multiple LLM providers have emerged, some have defined their own API specifications. Currently, the framework has integrated OpenAI and Anthropic APIs as described above. However, differences in instantiation methods and configuration options across providers mean that developers often need to modify substantial amounts of code when switching providers, increasing the switching cost. +To address this issue, tRPC-Agent supports unified multi-provider model access through [LiteLLM](https://docs.litellm.ai/), using the **provider/model** format (e.g., `openai/gpt-4o`, `anthropic/claude-3-5-sonnet`, `gemini/gemini-1.5-pro`), enabling switching between different backends with a single invocation pattern. LiteLLMModel inherits from OpenAIModel and only overrides the API call path to `litellm.acompletion`, simplifying the complexity of provider switching. + +### Environment Variable Configuration + +```bash +# API key of the model provider +export TRPC_AGENT_API_KEY="your-api-key" +# Base URL of the model provider (e.g., custom proxy or private deployment address) +export TRPC_AGENT_BASE_URL="your-base-url" +# Specify the model using provider/model format, e.g., openai/gpt-4o, anthropic/claude-3-5-sonnet +export TRPC_AGENT_MODEL_NAME="your-model-name" +``` + +### Explicit Model Creation via Code + +```python +from trpc_agent_sdk.models import LiteLLMModel +from trpc_agent_sdk.agents import LlmAgent + +model = LiteLLMModel( + model_name="openai/gpt-4o", # Required: provider/model + api_key="sk-xxx", # Required (or set environment variable TRPC_AGENT_API_KEY) + base_url="https://api.openai.com/v1", # Optional, required for self-hosted/proxy deployments +) +LlmAgent(..., model=model, instruction="...") +``` + +### Model Name Matching via Registry + +Without explicitly instantiating `LiteLLMModel`, you can pass only the model name string; the framework uses `ModelRegistry`'s `supported_models` regex patterns to match and create a LiteLLMModel instance. In this case, the API Key, base_url, and other settings rely on environment variables (e.g., `OPENAI_API_KEY`, `OPENAI_API_BASE`). + +```python +from trpc_agent_sdk.agents import LlmAgent + +LlmAgent(..., model="openai/gpt-4o", instruction="...") +``` + +### Multi-provider Examples + +| Provider | model_name Example | Environment Variable (Optional) | +|------|------------------|------------------| +| OpenAI | `openai/gpt-4o` | `OPENAI_API_KEY` | +| Anthropic | `anthropic/claude-3-5-sonnet` | `ANTHROPIC_API_KEY` | +| Google Gemini | `gemini/gemini-1.5-pro` | `GEMINI_API_KEY` | +| Self-hosted/Proxy | `openai/gpt-4o` | Requires `base_url` | + +For more usage and running examples, see [examples/litellm](../../../examples/litellm/README.md). + + +## Core Design +### LLMModel Class + +All concrete model implementations (e.g., `OpenAIModel`, `AnthropicModel`, `LiteLLMModel`) inherit from `LLMModel`. + +```python +class LLMModel(FilterRunner): + """Abstract base class for all model implementations.""" + + def __init__(self, model_name: str, filters_name: Optional[list[str]] = None, **kwargs): + # Extract model filter list from optional parameters + filters: list = kwargs.get("filters", []) + # Initialize parent class FilterRunner and attach filters to the current model + super().__init__(filters_name=filters_name, filters=filters) + # Save the model name, e.g., deepseek-chat, gpt-4o, etc. + self._model_name = model_name + # Save remaining initialization parameters for concrete model implementations to read as needed + self.config = kwargs + # Mark the current FilterRunner type as model + self._type = FilterType.MODEL + # Read the API Key from parameters + self._api_key: str = kwargs.get(const.API_KEY, "") + # Read the model service base URL from parameters + self._base_url: str = kwargs.get(const.BASE_URL, "") +``` + +### Request Structure + +The request structure consists of `RequestABC` and its subclass `LlmRequest`. Base fields are defined in `RequestABC`, and model invocation-related content includes the model name, message content, generation configuration, and tool dictionary. + +```python +class RequestABC(BaseModel): + model: Optional[str] = None + """Model name.""" + + contents: list[Content] = Field(default_factory=list) + """Message content sent to the model.""" + + config: Optional[GenerateContentConfig] = None + """Additional configuration for the generation request.""" + + live_connect_config: LiveConnectConfig = LiveConnectConfig() + """Additional configuration for live connection scenarios. + + Tools should not be set in generate_content_config. + """ + + tools_dict: dict[str, Any] = Field(default_factory=dict, exclude=True) + """Dictionary of tool instances.""" +``` + +Building on this, `LlmRequest` adds fields related to streaming tool invocation and provides several helper method implementations: + +```python +class LlmRequest(RequestABC): + streaming_tool_names: Optional[Set[str]] = None + """Set of tool names that need to receive streaming arguments.""" + + def append_instructions(self, instructions: list[str]) -> None: + """Append system instructions to the request.""" + ... + + def append_tools(self, tools: list[Any]) -> None: + """Append tools available for model invocation to the request.""" + ... + + def set_output_schema(self, base_model: type[BaseModel]) -> None: + """Set the Pydantic Schema for structured output.""" + ... +``` + +### Response Structure + +The response structure defines common fields via `ResponseABC`, while `LlmResponse` is responsible for converting the underlying `GenerateContentResponse` into the framework's unified response format. + +```python +class ResponseABC(BaseModel): + content: Optional[Content] = None + """Response content.""" + + grounding_metadata: Optional[GroundingMetadata] = None + """Grounding metadata in the response.""" + + partial: Optional[bool] = None + """Indicates whether the current text content is a fragment from an incomplete stream.""" + + turn_complete: Optional[bool] = None + """Indicates whether the current model response has completed.""" + + error_code: Optional[str] = None + """Error code, which may vary across different models.""" + + error_message: Optional[str] = None + """Error message.""" + + interrupted: Optional[bool] = None + """Indicates whether the model generation process was interrupted.""" + + custom_metadata: Optional[dict[str, Any]] = None + """Custom metadata attached to the LlmResponse.""" + + usage_metadata: Optional[GenerateContentResponseUsageMetadata] = None + """Usage statistics for the LlmResponse.""" + + response_id: Optional[str] = None + """Response ID returned by the model API.""" +``` + +## Advanced Features +### Streaming Output + +The Runner returns events in streaming mode by default. When `event.partial` is True, it indicates tokens being streamed from the LLM. Streaming can be disabled via `run_config=RunConfig(streaming=False)`. + +```python +from trpc_agent_sdk.configs import RunConfig + +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + run_config=RunConfig(streaming=False), # Disable streaming +): + ... +``` + +### Advanced Parameter Configuration + +Use `GenerateContentConfig` to adjust LLM generation behavior, such as temperature, top_p, max_output_tokens, etc.: + +```python +from trpc_agent_sdk.types import GenerateContentConfig + +LlmAgent( + name="weather_agent", + model=OpenAIModel(...), + instruction="...", + tools=[weather_tool], + generate_content_config=GenerateContentConfig( + temperature=0.1, + top_p=0.95, + max_output_tokens=1000, + ), +) +``` + +### Prompt Cache + +Prompt Cache is useful when system prompts are long, tool definitions are large, or multi-turn conversations share a stable prefix. Many providers, including OpenAI-compatible serving stacks such as `openai/sglang`, already support automatic prefix caching on the server side. `tRPC-Agent` does not replace the provider's cache implementation; instead, it provides unified management hints and normalized observability for prompt cache behavior. + +`tRPC-Agent` exposes these capabilities through `PromptCacheConfig`, which currently applies to `OpenAIModel`, `AnthropicModel`, and provider-prefixed `LiteLLMModel`. Because providers expose different cache controls and usage fields, the SDK maps management options and cache usage metrics to each provider protocol on a best-effort basis: + +| Provider | SDK Capability | Typical Usage Fields | +|----------|----------------|----------------------| +| Anthropic | Manages explicit `cache_control` breakpoints according to `breakpoints` | `cache_read_input_tokens`, `cache_creation_input_tokens` | +| OpenAI / OpenAI-compatible endpoints | Passes cache hints such as `prompt_cache_key` / `prompt_cache_retention` when supported; provider-side automatic prefix caching still owns cache creation and lookup | Usually only `cache_read_input_tokens` | +| LiteLLM | Chooses the Anthropic-style or OpenAI-style cache management path according to the `provider/model` prefix, while preserving provider-native automatic caching such as `openai/sglang` | Depends on the final provider route | + +#### Model-level configuration + +Model-level configuration becomes the default prompt-cache management and observability configuration for the model instance. Use it when all requests can share the same cache hints: + +```python +from trpc_agent_sdk.configs import PromptCacheConfig +from trpc_agent_sdk.models import OpenAIModel + +model = OpenAIModel( + model_name="gpt-4o", + api_key="your-api-key", + prompt_cache_config=PromptCacheConfig( + enabled=True, + ttl="24h", + prompt_cache_key="weather-concierge-v1", + ), +) +``` + +#### Per-run override + +You can also override prompt-cache settings for a single `runner.run_async()` call through `RunConfig.prompt_cache`. The per-run config overrides model-level settings field by field, which is useful when setting different cache hints by user, tenant, or business scenario: + +```python +from trpc_agent_sdk.configs import PromptCacheConfig +from trpc_agent_sdk.configs import RunConfig + +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, + run_config=RunConfig( + prompt_cache=PromptCacheConfig( + enabled=True, + prompt_cache_key="weather-concierge-user-42", + ), + ), +): + ... +``` + +#### Anthropic breakpoints + +Anthropic-style caching requires selecting breakpoint locations. `breakpoints` supports the following values: + +- `"system"`: cache the system prompt, suitable for long instructions +- `"tools"`: cache the last tool definition, suitable when tools are numerous or tool schemas are large +- `"messages"`: cache the most recent assistant message, suitable for growing stable history prefixes in multi-turn conversations + +```python +from trpc_agent_sdk.configs import PromptCacheConfig +from trpc_agent_sdk.models import AnthropicModel + +model = AnthropicModel( + model_name="claude-3-5-sonnet-20241022", + api_key="your-api-key", + prompt_cache_config=PromptCacheConfig( + enabled=True, + ttl="1h", + breakpoints=["tools", "system", "messages"], + ), +) +``` + +A good starting point is `["tools", "system"]`; add `"messages"` when long multi-turn conversations need to cache a growing history prefix. Some Anthropic proxies or Bedrock routes require a minimum cache block size, so short prompts may not create cache entries. + +#### LiteLLM routes + +When using `LiteLLMModel`, the model name should include a `provider/model` prefix. The SDK uses that provider prefix to select the appropriate cache-management mapping. For example: + +```python +from trpc_agent_sdk.configs import PromptCacheConfig +from trpc_agent_sdk.models import LiteLLMModel + +model = LiteLLMModel( + model_name="openai/gpt-4o", + api_key="your-api-key", + prompt_cache_config=PromptCacheConfig( + enabled=True, + prompt_cache_key="shared-prefix-v1", + ), +) +``` + +If the model name does not include a provider prefix, the SDK cannot determine which cache-management protocol to use, so SDK-managed cache hints may not take effect. + +#### Reading cache usage + +The model response's `usage_metadata` normalizes cache usage fields where possible: + +```python +async for event in runner.run_async(...): + usage = getattr(event, "usage_metadata", None) + if usage: + print(usage.cache_read_input_tokens) # Input tokens read from cache + print(usage.cache_creation_input_tokens) # Input tokens written to cache, usually only reported by Anthropic + print(usage.prompt_token_count) # Total input tokens +``` + +Different model services report different fields. OpenAI-compatible endpoints usually report cache reads but not cache writes. With load-balanced proxies, each backend instance may have its own KV cache and may not be warmed up at the same time, so cache hit rates can fluctuate during the first few runs. + +For a complete runnable example, see [examples/llmagent_with_prompt_cache](../../../examples/llmagent_with_prompt_cache/README.md). + +### Model Retry + +Model retry is useful when LLM requests encounter transient failures such as rate limits, timeouts, network fluctuations, or temporary service unavailability. By passing `ModelRetryConfig` when constructing a model, you can configure retry behavior at the model layer. When retryable exceptions occur, the SDK automatically retries the request and backs off according to the configured exponential backoff strategy. + +`OpenAIModel`, `AnthropicModel`, and `LiteLLMModel` all support model retry. This capability is disabled by default and is enabled only when `model_retry_config` is explicitly provided. + +#### Basic usage + +```python +from trpc_agent_sdk.configs import ExponentialBackoffConfig +from trpc_agent_sdk.configs import ModelRetryConfig +from trpc_agent_sdk.models import OpenAIModel + +model = OpenAIModel( + model_name="deepseek-chat", + api_key="your-api-key", + base_url="https://api.deepseek.com/v1", + model_retry_config=ModelRetryConfig( + num_retries=2, # Additional retry attempts after the initial request fails + backoff=ExponentialBackoffConfig( + initial_backoff=1.0, # Base delay in seconds before the first retry + max_backoff=8.0, # Upper bound in seconds for a single retry delay + multiplier=2.0, # Exponential backoff multiplier + jitter=True, # Whether to apply full jitter to avoid synchronized retries + ), + ), +) +``` + +#### Configuration fields + +| Field | Default | Description | +|-------|---------|-------------| +| `num_retries` | `2` | Additional retry attempts after the initial request fails; does not include the initial request. Set to `0` to disable additional retries | +| `backoff.initial_backoff` | `1.0` | Base delay in seconds before the first retry | +| `backoff.max_backoff` | `10.0` | Upper bound in seconds for a single retry delay | +| `backoff.multiplier` | `2.0` | Exponential backoff multiplier, e.g. retry delays are computed as `initial_backoff * multiplier^attempt` | +| `backoff.jitter` | `True` | Whether to apply full jitter. When enabled, the SDK waits for a random duration between `0` and the current backoff delay to avoid synchronized retries | + +If the provider returns `Retry-After` or `retry-after-ms`, and the suggested delay is within a reasonable range, the SDK uses the server-suggested delay first. Otherwise, it computes the delay from the exponential backoff configuration. + +#### Retry decisions + +The SDK combines response headers, HTTP status codes, and provider SDK exception semantics to decide whether to retry. The general precedence is: + +- `x-should-retry: true` forces the error to be treated as retryable, while `x-should-retry: false` forces it to be treated as non-retryable. +- HTTP status `408`, `409`, `429`, and `>=500` are generally retryable. +- Other `4xx` errors such as `400`, `401`, `403`, and `404` are generally not retried. +- Timeout and connection-related OpenAI / Anthropic exceptions are generally retryable. +- `LiteLLMModel` primarily uses the header and status information on LiteLLM-normalized exceptions. + +#### Streaming behavior + +To avoid duplicated output, model retry only happens before the current model call has produced user-visible content (text or tool calls). If a streaming response has already emitted partial content and then fails, the SDK returns a final error response directly instead of replaying the whole request. + +Runner code does not need additional retry handling: + +```python +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_content, +): + ... +``` + +If the model call encounters a retryable error before producing content, the SDK retries according to `ModelRetryConfig`. If the retry budget is exhausted or the error is non-retryable, the SDK surfaces an `LlmResponse` with `error_code` / `error_message`. + +For a complete runnable example, see [examples/llmagent_with_model_retry](../../../examples/llmagent_with_model_retry/README.md). + +### Custom HTTP Headers + +Pass additional headers via `client_args`'s `default_headers` or `GenerateContentConfig`'s `HttpOptions`, suitable for gateways, proprietary platforms, or proxy environments. For example: + +```python +OpenAIModel( + model_name="deepseek-chat", + base_url="...", + api_key="...", + client_args={"default_headers": {"X-Custom-Header": "custom-value", "X-Request-ID": "req-123"}}, +) +``` diff --git a/docs/mkdocs/en/multi_agents.md b/docs/mkdocs/en/multi_agents.md new file mode 100644 index 000000000..ff0991fdc --- /dev/null +++ b/docs/mkdocs/en/multi_agents.md @@ -0,0 +1,659 @@ +# Multi Agents + +Multi Agents is the core mechanism in the trpc_agent framework for orchestrating multiple Agents to work collaboratively. Unlike a single LlmAgent that focuses on specific tasks, Multi Agents combines multiple Agents through different orchestration patterns to automate complex workflow processing. + +## Overview + +### Difference Between Multi Agents and LlmAgent + +- **LlmAgent**: A single Agent that uses an LLM as its brain and completes specific tasks through tool invocation +- **Multi Agents**: A multi-Agent orchestration system that combines multiple LlmAgents in specific patterns, completing complex workflows through state passing and collaboration + +Multi Agents is built on the Sub Agent concept and supports the following orchestration patterns and auxiliary features: + +### Core Collaboration Patterns + +#### Chain Agent +- **Pattern**: Sequential execution, where the output of the previous Agent serves as input for the next Agent +- **Use cases**: Pipeline tasks requiring step-by-step processing, such as document processing (content extraction → translation) +- **Characteristics**: Linear execution, each Agent focuses on one stage of the pipeline + +#### Parallel Agent +- **Pattern**: Multiple Agents execute simultaneously, each independently processing the same input +- **Use cases**: Tasks requiring multi-perspective analysis, such as content review (quality check + security check) +- **Characteristics**: Concurrent execution, improved efficiency, multi-dimensional results + +#### Cycle Agent +- **Pattern**: Cyclic execution among multiple Agents until an exit condition is met +- **Use cases**: Tasks requiring iterative refinement, such as content creation (generate → evaluate → improve → re-evaluate) +- **Characteristics**: Iterative execution, continuous improvement, suitable for scenarios requiring multiple rounds of optimization + +#### Sub Agents +- **Pattern**: Hierarchical Agent structure where a parent Agent can forward tasks to specialized child Agents +- **Use cases**: Complex task decomposition, such as intelligent customer service (routing Agent → specialized consultation Agent → problem resolution Agent) +- **Characteristics**: Hierarchical structure, task distribution, specialized processing + +### Auxiliary Features + +- **AgentTool** — Wraps an Agent as a tool for other Agents to invoke via the `tools` parameter +- **TransferAgent (transfer proxy)** — Enables custom Agents without native transfer support to join multi-Agent systems + +### Deterministic Execution + +Unlike LlmAgent, the orchestration patterns of Multi Agents (Chain, Parallel, Cycle) are inherently **deterministic** and do not rely on an LLM to determine execution order or flow. This means: + +- **Chain Agent**: Always executes in the order of the sub_agents list, regardless of input +- **Parallel Agent**: Always executes all sub_agents simultaneously, regardless of input +- **Cycle Agent**: Executes in a fixed cyclic pattern until an explicit exit condition is met + +This determinism ensures workflow predictability and reliability, while individual LlmAgents within the workflow can still dynamically adjust their behavior based on input. + +## Core Collaboration Patterns + +### Chain Agent + +Chain Agent executes multiple Agents sequentially, forming a processing pipeline. It passes the output of the previous Agent to the next Agent via `output_key`, enabling sequential data passing and processing. + +#### Use Cases + +- **Content creation pipeline**: Planning → Research → Writing +- **Document processing pipeline**: Extraction → Translation → Proofreading +- **Problem solving pipeline**: Analysis → Design → Implementation + +#### Basic Usage + +```python +from trpc_agent_sdk.agents import ChainAgent, LlmAgent + +# Step 1: Content Extraction Agent +extractor_agent = LlmAgent( + name="content_extractor", + model="deepseek-v3-local-II", + instruction="Extract key information from the input text and structure it clearly.", + output_key="extracted_content" # Save output to a state variable +) + +# Step 2: Translation Agent, referencing the previous Agent's output +translator_agent = LlmAgent( + name="translator", + model="deepseek-v3-local-II", + instruction="""Translate the following extracted content to English: + +{extracted_content} + +Provide a natural, professional English translation with clear structure and formatting.""", + output_key="translated_content" # Save the translation result to a state variable +) + +# Create a Chain Agent +processing_chain = ChainAgent( + name="document_processor", + description="Sequential document processing: extract → translate", + sub_agents=[extractor_agent, translator_agent], +) +``` + +#### Architecture + +``` +Chain Agent (document_processor) +│ +├── Step 1: Content Extraction Agent +│ └── output_key="extracted_content" +│ +└── Step 2: Translation Agent + ├── Reads {extracted_content} + └── output_key="translated_content" +``` + +### Parallel Agent + +Parallel Agent executes multiple Agents simultaneously, suitable for scenarios requiring multi-perspective analysis or parallel processing. Each Agent saves its independent analysis results via `output_key`. + +#### Use Cases + +- **Business decision analysis**: Market analysis, technical assessment, and risk assessment running concurrently +- **Content review**: Quality review + security review executed in parallel +- **Multi-dimensional evaluation**: Different experts evaluating the same issue simultaneously + +#### Basic Usage + +```python +from trpc_agent_sdk.agents import ParallelAgent, LlmAgent + +# Quality Review Agent +quality_reviewer = LlmAgent( + name="quality_reviewer", + model="deepseek-v3-local-II", + instruction="""Review content quality: clarity, accuracy, readability. +Provide quality score (1-10) and brief feedback.""", + output_key="quality_review" +) + +# Security Review Agent +security_reviewer = LlmAgent( + name="security_reviewer", + model="deepseek-v3-local-II", + instruction="""Review security concerns: data privacy, vulnerabilities. +Provide security score (1-10) and identify risks.""", + output_key="security_review" +) + +# Create a Parallel Agent +review_panel = ParallelAgent( + name="review_panel", + description="Parallel review: quality + security", + sub_agents=[quality_reviewer, security_reviewer], +) +``` + +### Cycle Agent + +Cycle Agent executes cyclically among multiple Agents, suitable for tasks requiring iterative refinement. It passes information during cycles via `output_key` and controls cycle exit through the exit tool. + +#### Use Cases + +- **Content refinement**: Generate → Evaluate → Improve → Repeat +- **Problem solving**: Propose → Evaluate → Enhance → Repeat +- **Quality assurance**: Draft → Review → Revise → Repeat + +#### Cycle Control Mechanism + +Cycle Agent provides two ways to exit the cycle: + +1. **Tool exit**: By invoking a specific tool within an Agent, set `InvocationContext.actions.escalate = True` to actively exit +2. **Maximum iterations**: Set the maximum number of cycles via the `max_iterations` parameter to prevent infinite loops + +Cycle Agent runs sub_agents in order, then repeats the entire process. It stops when any of the following occurs: + +1. A tool invocation sets `actions.escalate = True` +2. The `max_iterations` limit is reached +3. The context is cancelled (timeout / manual cancellation) + +**Default behavior**: If no exit tool is configured, Cycle Agent will only stop when `max_iterations` is reached or an error is encountered. + +**Best practices**: +- Always set a reasonable `max_iterations` value (e.g., 3-10) as a safety net +- Provide clear exit conditions and tool invocations in the evaluator Agent +- Ensure the exit tool's trigger conditions are sufficiently explicit to avoid premature or delayed exits +- Keep exit tool functions lightweight, side-effect-free, and with proper `None` / parsing failure handling + +#### Basic Usage + +```python +from trpc_agent_sdk.agents import CycleAgent, LlmAgent, InvocationContext +from trpc_agent_sdk.tools import FunctionTool + +def exit_refinement_loop(tool_context: InvocationContext): + """Tool function to stop the content refinement loop""" + tool_context.actions.escalate = True + return {"status": "content_approved", "message": "Content quality is satisfactory"} + +# Content Writer Agent +content_writer = LlmAgent( + name="content_writer", + model="deepseek-v3-local-II", + instruction="""Create high-quality content based on the user's request. + +If this is the first iteration, create original content. +If there's existing content with feedback, improve it based on the suggestions: + +Existing content: {current_content} +Feedback: {feedback} + +Output only the improved content.""", + output_key="current_content" # Save the current content to a state variable +) + +# Content Evaluator Agent +content_evaluator = LlmAgent( + name="content_evaluator", + model="deepseek-v3-local-II", + instruction="""Evaluate the following content for quality: + +{current_content} + +Assessment criteria: +- Clarity and readability (score 1-10) +- Structure and organization (score 1-10) +- Completeness and accuracy (score 1-10) + +If ALL scores are 8 or above, call the exit_refinement_loop tool immediately. +If any score is below 8, provide specific feedback for improvement.""", + output_key="feedback", # Save feedback to a state variable + tools=[FunctionTool(exit_refinement_loop)] +) + +# Create a Cycle Agent +content_refinement_cycle = CycleAgent( + name="content_refinement_loop", + description="Iterative content refinement: write → evaluate → improve", + max_iterations=5, # Maximum number of cycles to prevent infinite loops + sub_agents=[content_writer, content_evaluator], +) +``` + +### Sub Agents (Agent Delegation) + +Sub Agents implement intelligent task distribution through a hierarchical structure, where a parent Agent can forward requests to the most suitable child Agent using `transfer_to_agent`. + +When an `LlmAgent` is configured with the `sub_agents` parameter, the framework automatically injects the `transfer_to_agent` tool, allowing the main Agent to select the appropriate Sub Agent based on the task type. + +#### Use Cases + +- **Task classification**: Automatically select the appropriate Sub Agent based on user requests +- **Intelligent routing**: Route complex tasks to the most suitable handler +- **Specialized processing**: Each Sub Agent focuses on a specific domain +- **Seamless switching**: Seamlessly switch between Sub Agents while maintaining conversation continuity + +#### Basic Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import FunctionTool + +# Technical Support Specialist +technical_support_agent = LlmAgent( + name="technical_support", + model="deepseek-v3-local-II", + instruction="""You are a technical support specialist. +Help with device troubleshooting and system diagnostics. +Use check_system_status tool to check device status.""", + tools=[FunctionTool(check_system_status)], + # Prevent transferring control back to the parent Agent + disallow_transfer_to_parent=True, + output_key="technical_result" +) + +# Sales Consultant +sales_consultant_agent = LlmAgent( + name="sales_consultant", + model="deepseek-v3-local-II", + instruction="""You are a sales consultant. Help customers with product information. +Use get_product_info tool with: speakers, displays, or security.""", + tools=[FunctionTool(get_product_info)], + # Prevent transferring control back to the parent Agent + disallow_transfer_to_parent=True, + output_key="sales_result" +) + +# Main Customer Service Coordinator +customer_service_coordinator = LlmAgent( + name="customer_service_coordinator", + model="deepseek-v3-local-II", + instruction="""You are a customer service coordinator. +Route customer inquiries: +- Technical issues → transfer to technical_support +- Product questions → transfer to sales_consultant""", + sub_agents=[technical_support_agent, sales_consultant_agent], + output_key="coordinator_result" +) +``` + +#### Delegation Architecture + +``` +Coordinator Agent (Main Entry Point) +├── Analyze user request +├── Select the appropriate Sub Agent +└── Delegate the task using the transfer_to_agent tool + ├── Technical Support Sub Agent (Device Diagnostics) + └── Sales Consultant Sub Agent (Product Information) +``` + +#### Transfer Control Options + +`LlmAgent` provides the following parameters to control transfer behavior between Agents: + +| Parameter | Default | Description | +|------|--------|------| +| `disallow_transfer_to_parent` | `False` | Set to `True` to prevent a child Agent from transferring control back to the parent Agent | +| `disallow_transfer_to_peers` | `False` | Set to `True` to prevent a child Agent from transferring control to peer Agents | +| `default_transfer_message` | `None` | Custom transfer instruction that overrides the default transfer prompt | + +#### Spawned Sub-Agents + +As an alternative to persistent `sub_agents` (transfer-based), you can spawn +short-lived sub-agents at run time via ``SpawnSubAgentTool`` (pick from a +pre-registered catalog) or ``DynamicSubAgentTool`` (LLM defines the role on the +fly). See [Sub-Agent Tools](sub_agent.md). + +## Compose Patterns (Compose Agents) + +Different orchestration patterns can be flexibly combined, connecting results of different stages via `output_key` to create more complex workflows: + +```python +# Stage 1: Parallel Analysis Stage +parallel_analysis_stage = ParallelAgent( + name="parallel_analysis_team", + description="Parallel quality and security analysis", + sub_agents=[quality_analyst, security_analyst], +) + +# Stage 2: Integrated Report Generation, referencing parallel analysis results +report_generator = LlmAgent( + name="report_generator", + model="deepseek-v3-local-II", + instruction="""Generate analysis report based on: + +Quality Analysis: {quality_analysis} +Security Analysis: {security_analysis} + +Create summary with overall assessment and recommendations.""", + output_key="final_report" +) + +# Compose: Parallel Analysis → Integrated Report +analysis_pipeline = ChainAgent( + name="analysis_pipeline", + description="Parallel analysis → integrated report", + sub_agents=[parallel_analysis_stage, report_generator], +) +``` + +More composition patterns: + +```python +# Chain + Cycle: Embedding iterative refinement within a pipeline +pipeline_with_refinement = ChainAgent( + name="pipeline_with_refinement", + sub_agents=[ + data_collector, # Step 1: Data Collection + content_refinement_cycle, # Step 2: Iterative Refinement (CycleAgent) + final_formatter, # Step 3: Final Formatting + ], +) + +# Team as Sub Agent: Team nested within a larger orchestration +team_based_pipeline = ChainAgent( + name="team_pipeline", + sub_agents=[ + requirement_analyzer, # Step 1: Requirement Analysis + content_team, # Step 2: Team Collaboration (TeamAgent) + quality_reviewer, # Step 3: Quality Review + ], +) +``` + +## Auxiliary Features + +### AgentTool + +AgentTool allows wrapping any Agent as a callable tool for other Agents to use via the `tools` parameter. Unlike the **control transfer** of `transfer_to_agent`, AgentTool uses a **function call** pattern — the main Agent invokes the child Agent as a tool, retrieves the result, and continues its own processing flow. + +#### Use Cases + +- **Specialized delegation**: The main Agent delegates specific tasks to a specialized Agent, retrieves results, and continues processing +- **Tool integration**: Encapsulates Agent capabilities as reusable tool components +- **Modular design**: Agents can be composed and reused like regular tools +- **Retained control**: The main Agent always retains control; the child Agent is only invoked as a tool + +#### Basic Usage + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import AgentTool + +# Create a specialized Translation Agent +translator_agent = LlmAgent( + name="translator", + model="deepseek-chat", + description="A professional text translation tool", + instruction="You are a professional translation tool capable of accurately translating text between Chinese and English.", +) + +# Wrap the Agent as a tool +translator_tool = AgentTool(agent=translator_agent) + +# Use the Agent tool in the main Agent +main_agent = LlmAgent( + name="content_processor", + model="deepseek-chat", + description="Content processing assistant", + instruction="You are a content processing assistant that can invoke the translation tool to handle multilingual content.", + tools=[translator_tool], +) +``` + +#### Architecture + +``` +Content Processing Assistant (Main Agent) +├── Translation Tool (AgentTool) +│ └── Translation Agent (Specialized Agent) +├── Other Tools (FunctionTool) +└── ... +``` + +#### AgentTool Parameters + +| Parameter | Type | Default | Description | +|------|------|--------|------| +| `agent` | AgentABC | Required | The Agent to be wrapped | +| `skip_summarization` | bool | False | Whether to skip summarization | +| `filters_name` | list[str] | None | Associated filter names | +| `filters` | list[BaseFilter] | None | Filter instances | + + +#### AgentTool vs transfer_to_agent Comparison + +| Feature | AgentTool | transfer_to_agent | +|------|-----------|-------------------| +| Control | Main Agent retains control | Control is transferred to the child Agent | +| Invocation | Invoked as a tool function | Automatically injected via `sub_agents` | +| Return | Tool returns results to the main Agent | Child Agent responds directly to the user | +| Use case | When the main Agent needs to synthesize multiple results | When the child Agent needs to handle tasks independently | + + +### TransferAgent (Transfer Proxy) + +TransferAgent is a transfer proxy Agent designed to enable custom Agents without transfer capability (such as `TrpcRemoteA2aAgent`) to gain transfer capability, thereby integrating them into the tRPC-Agent framework's multi-Agent system. + +`TrpcRemoteA2aAgent` can be used to simulate a remote Agent scenario (first start an A2A Agent service with [examples/a2a/run_server.py](../../../examples/a2a/run_server.py) to mimic a remote Agent). + +Through TransferAgent, custom Agents can: +- **Act as sub_agent**: The parent Agent can transfer control to this Agent, and this Agent can transfer control to parent/sibling Agents +- **Configure sub_agents**: Intelligently transfer control to other Agents based on the custom Agent's invocation results + +#### Scenario 1: Acting as sub_agent + +No need to provide `sub_agents` or `transfer_instruction` — TransferAgent directly makes the target Agent callable by other Agents. + +```python +from trpc_agent_sdk.agents import TransferAgent, LlmAgent +from trpc_agent_sdk.server.a2a import TrpcRemoteA2aAgent + +# Create a custom Agent (without transfer capability); use TrpcRemoteA2aAgent to simulate a remote Agent +remote_agent = TrpcRemoteA2aAgent( + name="remote-weather-assistant", + description="A remote weather agent served over A2A", + agent_base_url="http://127.0.0.1:18081", # Remote A2A service URL +) +# Note: TrpcRemoteA2aAgent should be initialized during application startup +# await remote_agent.initialize() + +# Enable transfer capability for remote_agent via TransferAgent +transfer_agent = TransferAgent( + remote_agent, + model=model, +) + +# Now remote_agent (via transfer_agent) can be invoked as a sub_agent +coordinator = LlmAgent( + name="coordinator", + model=model, + sub_agents=[transfer_agent], +) +``` + +#### Scenario 2: Configuring sub_agents + +After providing `sub_agents`, TransferAgent analyzes the target Agent's returned results and intelligently forwards them to different child Agents for further processing. `transfer_instruction` is optional — when not provided, default rules are used. + +```python +from trpc_agent_sdk.agents import TransferAgent, LlmAgent +from trpc_agent_sdk.server.a2a import TrpcRemoteA2aAgent + +# Create a custom Agent (without transfer capability); use TrpcRemoteA2aAgent +remote_agent = TrpcRemoteA2aAgent( + name="remote-weather-assistant", + description="A remote weather agent served over A2A", + agent_base_url="http://127.0.0.1:18081", +) +# Note: TrpcRemoteA2aAgent should be initialized during application startup +# await remote_agent.initialize() + +# Create child Agents +data_analyst = LlmAgent( + name="data_analyst", + model=model, + description="Performs data analysis and generates insights", + instruction="You are a data analyst...", +) + +# Method 1: Provide custom transfer rules +transfer_agent = TransferAgent( + remote_agent, + model=model, + sub_agents=[data_analyst], + transfer_instruction=( + "After remote-weather-assistant returns results, analyze the response:\n" + "1. If the result contains data or statistics, transfer to data_analyst.\n" + "2. Otherwise, return directly to the user." + ), +) + +# Method 2: Use default transfer rules (do not provide transfer_instruction) +transfer_agent = TransferAgent( + remote_agent, + model=model, + sub_agents=[data_analyst], +) +``` + +The default rules automatically analyze the target Agent's results: +- If the content requires a child Agent's specialized capability (matching the child Agent's description), it will transfer +- If the content contains errors or incomplete information, it will consider transferring +- If the content is complete and satisfactory, it will not transfer +- Selects the most suitable Agent based on child Agent descriptions + +#### Configuration Parameters + +| Parameter | Type | Required | Description | +|------|------|------|------| +| `agent` | BaseAgent | Yes | The target Agent; TransferAgent will enable transfer capability for this Agent | +| `model` | Union[str, LLMModel, Callable] | Yes | The LLM model used for transfer decisions (only used when `sub_agents` is provided) | +| `sub_agents` | List[AgentABC] | No | List of child Agents; when provided, TransferAgent analyzes the target Agent's results and decides whether to transfer | +| `transfer_instruction` | str | No | Custom transfer rules; when empty but `sub_agents` is provided, default rules are used automatically | + +- **`agent`**: The target Agent, required. TransferAgent wraps this Agent to enable transfer capability +- **`model`**: The LLM model, required. Used to analyze the target Agent's results and decide whether to transfer (only used when `sub_agents` is provided) +- **`sub_agents`**: Optional. When provided, TransferAgent will: + 1. Invoke the target Agent and collect results + 2. Use the LLM to analyze results + 3. Decide whether to transfer to a child Agent based on `transfer_instruction` (or default rules) +- **`transfer_instruction`**: Optional. Custom transfer rules, only effective when `sub_agents` is provided. When empty, a default general-purpose rule is used automatically + +#### Use Cases + +- **Scenario 1 (Acting as sub_agent)**: Without providing `sub_agents`, TransferAgent exposes the target Agent so other Agents can invoke it (e.g. as a sub_agent) +- **Scenario 2 (Forwarding to other sub_agents)**: When `sub_agents` is provided, TransferAgent analyzes the target Agent's results and decides whether to transfer to a child Agent. `transfer_instruction` is optional — when not provided, default rules are used + +#### Agent Naming + +TransferAgent's name is automatically generated in the format `transfer_{target_agent_name}`. For example: +- If the target Agent's name is `remote-weather-assistant`, the TransferAgent's name will be `transfer_remote-weather-assistant` +- If the target Agent's name is `custom-agent`, the TransferAgent's name will be `transfer_custom-agent` + +#### Notes + +1. **Model requirement**: `model` is a required parameter, used for transfer decisions (only used when `sub_agents` is provided) +2. **Default rules**: If `sub_agents` is provided without `transfer_instruction`, a default general-purpose transfer rule is used automatically +3. **Transfer rule design**: In Scenario 2, it is recommended to provide clear `transfer_instruction` to help the LLM accurately determine when and where to transfer +4. **Target Agent restrictions**: The target Agent cannot be the TransferAgent itself, nor can it be in the `sub_agents` list (it will be automatically removed if present) + + +## State Passing and Context Management + +Within Multi Agents, information is passed between Agents through the `output_key` mechanism. Each Agent can save its output to a state variable, and subsequent Agents reference it using the `{var}` syntax: + +### How State Variables Work + +1. **Storage**: When an Agent has `output_key` set, its output is automatically saved to the session's state dictionary +2. **Reference**: Use the `{variable_name}` syntax in instructions to insert state variable values +3. **Scope**: State variables are shared across the entire session and accessible to all Agents +4. **Override**: If multiple Agents use the same `output_key`, the later-executing Agent will overwrite the previous value + +### State Variable Best Practices + +- **Naming conventions**: Use descriptive variable names, such as `extracted_content`, `quality_review`, etc. +- **Avoid conflicts**: Ensure different Agents have unique `output_key` values, unless intentional overwriting is desired +- **Type consistency**: Maintain consistent data types for state variables to facilitate processing by subsequent Agents +- **Documentation**: Clearly describe the expected state variable format in instructions + +```python +# An Agent can save its output to a state variable +content_analyzer = LlmAgent( + name="content_analyzer", + model="deepseek-v3-local-II", + instruction="Analyze the input content and provide detailed insights.", + output_key="analysis_result", # Save output to a state variable +) + +# Subsequent Agents can reference previous results via templates +report_writer = LlmAgent( + name="report_writer", + model="deepseek-v3-local-II", + instruction="""Generate a comprehensive report based on the analysis: + +**Analysis Results:** +{analysis_result} + +Create a structured report with summary, key findings, and recommendations.""", # Reference the state variable + output_key="final_report" +) +``` + +### Advanced State Management + +In addition to the basic `output_key` mechanism, state can also be managed through the following approaches: + +```python +# Access the full session state at runtime +session = await session_service.get_session( + app_name=APP_NAME, + user_id=USER_ID, + session_id=session_id +) + +# Access all state variables +if session and session.state: + analysis_result = session.state.get("analysis_result") + quality_review = session.state.get("quality_review") + security_review = session.state.get("security_review") +``` + +### Removing Previous Agent Messages +In a session, sometimes messages from previously executed Agents have no relevance to the current Agent's execution. You can set the `include_previous_history` parameter to prevent messages from previous Agents from being concatenated into the current Agent's context, as shown below: + +```python +LlmAgent( + ..., + include_previous_history=False, +) +``` + +## Complete Examples + +For complete examples of various Multi Agents patterns, see: + +### Core Collaboration Pattern Examples +- Chain Agent Example: [examples/multi_agent_chain/run_agent.py](../../../examples/multi_agent_chain/run_agent.py) +- Parallel Agent Example: [examples/multi_agent_parallel/run_agent.py](../../../examples/multi_agent_parallel/run_agent.py) +- Cycle Agent Example: [examples/multi_agent_cycle/run_agent.py](../../../examples/multi_agent_cycle/run_agent.py) +- Compose Pattern Example: [examples/multi_agent_compose/run_agent.py](../../../examples/multi_agent_compose/run_agent.py) +- Sub Agents Example: [examples/multi_agent_subagent/run_agent.py](../../../examples/multi_agent_subagent/run_agent.py) + +### Auxiliary Feature Examples +- AgentTool Example: [examples/agent_tools/run_agent.py](../../../examples/agent_tools/run_agent.py) +- TransferAgent Example: [examples/transfer_agent/run_agent.py](../../../examples/transfer_agent/run_agent.py) diff --git a/docs/mkdocs/en/openclaw.md b/docs/mkdocs/en/openclaw.md new file mode 100644 index 000000000..98d304b6d --- /dev/null +++ b/docs/mkdocs/en/openclaw.md @@ -0,0 +1,403 @@ +# OpenClaw (trpc_claw) + +`openclaw` (also called `trpc_claw`) is an agent runtime built on top of `trpc_agent_sdk` and `nanobot`, supporting: + +- Third-party channel integrations (for example: Telegram / WeCom and other channels supported by nanobot). OpenClaw supports all of these channels. See [nanobot/channels](https://github.com/HKUDS/nanobot/tree/main/nanobot/channels). +- Local CLI fallback mode +- Tool invocation (file, shell, web, messaging, scheduled tasks, MCP, skills) +- Session/memory management and summarization +- Heartbeat/Cron scheduled execution + +Default workspace: `~/.trpc_claw/workspace` + +## Core Capabilities + +- **Unified processing pipeline**: whether messages come from Telegram / WeCom or local CLI, they all flow through the same `MessageBus -> Runner -> Agent` pipeline +- **Two run modes** + - `run`: use gateway when channels are available; automatically fall back to CLI when none are enabled + - `chat`: force local CLI mode +- **Extensible tool system**: built-in file tools, command execution, web tools, messaging, cron, MCP, and skills +- **Long-running operation support**: heartbeat and cron can trigger tasks even without active user interactions + +## Architecture Diagram + +```mermaid +flowchart TD + A[trpc_agent_cmd openclaw] --> B[Typer Commands: run/chat/ui/conf_temp/deps] + B --> C[ClawApplication] + C --> D[load_config] + C --> E[MessageBus] + C --> F[ChannelManager] + C --> G[Runner + LlmAgent] + G --> H[Tools / Skills / MCP] + C --> I[CronService] + C --> J[HeartbeatService] + + F -->|enabled channels| K[Telegram / WeCom / other channels] + B -->|chat or no channel| L[CLI fallback loop] + E --> G + I --> G + J --> G +``` + +## Installation and Startup + +### 1) Environment Preparation + +- Python `>=3.10` (recommended `3.12`) +- Use a virtual environment (`uv` or `venv`) + +```bash +uv venv +source .venv/bin/activate +python -V +``` + +### 2) CLI Entry Point + +Current recommended command entry point (aligned with the repository state): + +```bash +trpc_agent_cmd openclaw --help +``` + +### 3) Generate Config Templates + +```bash +mkdir -p ~/.trpc_claw + +# Minimal template +trpc_agent_cmd openclaw conf_temp > ~/.trpc_claw/config.yaml + +# Full template (recommended) +trpc_agent_cmd openclaw conf_temp --full > ~/.trpc_claw/config_full.yaml +``` + +### 4) Startup Modes + +```bash +# Auto mode: use gateway if channels are available; otherwise fall back to CLI +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml + +# Force local CLI interaction +trpc_agent_cmd openclaw chat -c ~/.trpc_claw/config_full.yaml + +# Start UI +trpc_agent_cmd openclaw ui -c ~/.trpc_claw/config_full.yaml +``` + +## Command Reference + +- `trpc_agent_cmd openclaw conf_temp [--full]` + - Print built-in config templates (`config.temp.yaml` / `config_full.temp.yaml`) +- `trpc_agent_cmd openclaw run [-w WORKSPACE] [-c CONFIG]` + - Start gateway mode; automatically fall back to CLI if no third-party channel is available +- `trpc_agent_cmd openclaw chat [-w WORKSPACE] [-c CONFIG]` + - Always use local CLI mode and ignore third-party channels +- `trpc_agent_cmd openclaw ui [-w WORKSPACE] [-c CONFIG]` + - Start UI (macOS desktop app, browser on other systems) +- `trpc_agent_cmd openclaw deps [OPTIONS]` + - Inspect skill dependencies and output an installation plan; supports `--apply` to execute install commands + +### `deps` Subcommand (New Capability) + +```bash +# Inspect by profile (recommended) +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools + +# Inspect by skill +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --skills {skill-finder} # Placeholder skill name for downloading skills; replace with your real skill name + +# Output JSON +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools \ + --json + +# Execute the installation plan directly +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools \ + --apply +``` + +Common options: + +- `--profile`: dependency profile(s) (comma-separated) +- `--skills/-s`: inspect dependencies by skill name (comma-separated) +- `--state-dir`: toolchain state directory (alignment parameter) +- `--skills-root`: override skills root +- `--skills-extra-dirs`: extra skills roots (comma-separated) +- `--skills-allow-bundled`: override bundled skill allowlist +- `--continue-on-error/--fail-fast`: installation execution strategy + +## Config File + +Configuration supports `YAML/JSON`, default lookup path: + +- `~/.trpc_claw/config.json` + +You can override via: + +- CLI argument: `-c/--config` +- Environment variable: `TRPC_CLAW_CONFIG` + +Example: + +```bash +export TRPC_CLAW_CONFIG=/path/to/config.yaml +trpc_agent_cmd openclaw chat +``` + +## Key Config Sections (Current Implementation) + +### runtime + +- `app_name`: runtime app name +- `user_id`: default user ID +- `legacy_sessions_dir`: compatibility path for legacy session storage + +### agent + +- `workspace`: workspace directory (falls back to default when empty) +- `model`: model name (recommended via `TRPC_AGENT_MODEL_NAME`) +- `api_key`: model API key (recommended via `TRPC_AGENT_API_KEY`) +- `api_base`: model base URL (recommended via `TRPC_AGENT_BASE_URL`) +- `provider/max_tokens/context_window_tokens/...`: model behavior and cost-related settings + +### channels + +- `send_progress`: whether to push streaming progress +- `send_tool_hints`: whether to push tool hints +- `telegram.*`: Telegram channel config +- `wecom.*`: WeCom channel config + - `stream_reply`: whether to send stream chunks + - `restart_command`: command for worker process restart + config reload (default `/restart`) + +### skills (Important Updates) + +Fields currently used by `openclaw`: + +- `sandbox_type`: currently supports `local` / `container` +- `skill_roots`: user skill roots (supports local dirs, `file://`, `http(s)://`) +- `builtin_skill_roots`: built-in skill roots +- `config_keys`: used to satisfy `requires.config` in skill metadata +- `allow_bundled`: allowlist when only selected built-in skills should be exposed (by `skill_key` or `name`) +- `skill_configs`: runtime config by `skill_key` or `name` + - `enabled` + - `env` (now unified as env-only; `api_key/primary_env` mapping is removed) +- `local_config` / `container_config`: sandbox-specific config +- `run_tool_kwargs`: passthrough runtime args for `skill_run` + +Example: + +```yaml +skills: + sandbox_type: container + skill_roots: [] + builtin_skill_roots: [] + config_keys: + - skillhub.enabled + - skillhub + allow_bundled: + - skillhub.skill.finder + - skillhub-finder + skill_configs: + skillhub-skill-finder: + enabled: true + env: + SKILLHUB_USERNAME: ${SKILLHUB_USERNAME} + SKILLHUB_TOKEN: ${SKILLHUB_TOKEN} +``` + +Note: `skillhub-skill-finder` is only an example. Replace it with your actual skill name in real usage. + +### tools + +- `restrict_to_workspace`: restrict tools to operate inside workspace only +- `exec.timeout` / `exec.path_append`: command execution config +- `web.search`: search provider config (`brave`/`tavily`/`duckduckgo`/`searxng`/`jina`) +- `mcp_servers`: MCP server list (stdio/http) + +### memory / storage / logger / personal + +- `memory.memory_service_config.ttl`: memory TTL policy +- `storage`: `file` / `redis` / `sql` +- `logger`: logging config +- `personal`: override paths for `SOUL.md/USER.md/TOOLS.md/AGENTS.md` + +## Recommended Environment Variables + +- `TRPC_AGENT_API_KEY` +- `TRPC_AGENT_BASE_URL` +- `TRPC_AGENT_MODEL_NAME` +- `WECOM_BOT_ID` +- `WECOM_BOT_SECRET` +- `TELEGRAM_BOT_TOKEN` + +## Default Directories + +- Config directory: `~/.trpc_claw/` +- Workspace: `~/.trpc_claw/workspace` + +## Integrate with ClawBot + +### WeCom + +After creating a WeCom bot, get `BotID/BotSecret` and configure: + +```yaml +channels: + wecom: + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + stream_reply: true + restart_command: /restart +``` + +Start: + +```bash +export TRPC_AGENT_API_KEY=xxx +export TRPC_AGENT_BASE_URL=xxx +export TRPC_AGENT_MODEL_NAME=xxx +export WECOM_BOT_ID=xxx +export WECOM_BOT_SECRET=xxx +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml +``` + +![wecom](./image/wecom.png) + +### Telegram + +Reference: https://cloud.tencent.com/developer/article/2626214 + +```yaml +channels: + telegram: + enabled: true + token: ${TELEGRAM_BOT_TOKEN} +``` + +Start: + +```bash +export TELEGRAM_BOT_TOKEN=xxx +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml +``` + +![telegram](./image/telegram.png) + +## Advanced Features + +### 1) Multiple Storage Backends (`storage`) + +```yaml +storage: + type: redis # file | redis | sql + redis: + url: redis://127.0.0.1:6379 + is_async: false + password: "" + db: 0 + kwargs: {} +``` + +```yaml +storage: + type: sql + sql: + url: sqlite:///./session_memory.db + is_async: false + kwargs: {} +``` + +Notes: + +- `type=file`: short-term sessions are persisted by default to `workspace/sessions/*.jsonl` +- `type=redis/sql`: short-term sessions and memory can use shared backends + +### 2) Memory TTL (`memory.memory_service_config.ttl`) + +```yaml +memory: + memory_service_config: + enabled: true + ttl: + enable: true + ttl_seconds: 86400 + cleanup_interval_seconds: 3600 + update_time: 0.0 +``` + +### 3) Agent Advanced Parameters (`agent`) + +```yaml +agent: + provider: auto + max_tokens: 8192 + context_window_tokens: 65536 + temperature: 0.1 + max_tool_iterations: 40 + reasoning_effort: null # low | medium | high + extra_headers: {} +``` + +### 4) MCP Server Integration (`tools.mcp_servers`) + +```yaml +tools: + mcp_servers: + fs: + type: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + env: {} + tool_timeout: 30 + enabled_tools: ["*"] +``` + +### 5) Search Provider Extension (`tools.web.search`) + +```yaml +tools: + web: + search: + provider: brave # brave | tavily | duckduckgo | searxng | jina + api_key: "" + base_url: "" + max_results: 5 +``` + +### 6) Logging and Personal Prompt Files + +```yaml +logger: + name: trpc_claw + log_file: trpc_claw.log + log_level: INFO + log_format: "[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s" +``` + +```yaml +personal: + soul_file: /path/to/SOUL.md + user_file: /path/to/USER.md + tool_file: /path/to/TOOLS.md + agent_file: /path/to/AGENTS.md +``` + +## Directory Reference (Current Repository) + +- [`trpc_agent_sdk/server/openclaw/_cli.py`](../../../trpc_agent_sdk/server/openclaw/_cli.py): OpenClaw CLI commands +- [`trpc_agent_sdk/server/openclaw/claw.py`](../../../trpc_agent_sdk/server/openclaw/claw.py): runtime orchestration core +- [`trpc_agent_sdk/server/openclaw/config/`](../../../trpc_agent_sdk/server/openclaw/config/): config models and loading logic +- [`trpc_agent_sdk/server/openclaw/channels/`](../../../trpc_agent_sdk/server/openclaw/channels/): channel adapters (Telegram / WeCom) +- [`trpc_agent_sdk/server/openclaw/tools/`](../../../trpc_agent_sdk/server/openclaw/tools/): tool implementations +- [`trpc_agent_sdk/server/openclaw/skill/`](../../../trpc_agent_sdk/server/openclaw/skill/): skill system (loading, parsing, dependency inspection) +- [`trpc_agent_sdk/server/openclaw/service/`](../../../trpc_agent_sdk/server/openclaw/service/): Cron / Heartbeat services diff --git a/docs/mkdocs/en/optimization.md b/docs/mkdocs/en/optimization.md new file mode 100644 index 000000000..c40c2c04a --- /dev/null +++ b/docs/mkdocs/en/optimization.md @@ -0,0 +1,2030 @@ +# Prompt Self-Optimization (AgentOptimizer) + +`AgentOptimizer` is the **prompt self-optimization module** of tRPC-Agent-Python: it transforms the iterative process of prompt engineering—failure case analysis, rewriting, regression validation, version management—into a reproducible automated pipeline, freeing engineers from manual trial and error. + +> **The scope of "prompt" here**: In agent applications, "prompt" refers not only to the narrow system prompt, but also to all natural language assets that drive agent behavior—skill descriptions, rule specifications, sub-agent coordination instructions, tool usage instructions, etc. Their essence is natural language text interpreted by LLMs; as long as they influence agent decisions, they can be optimization targets for `AgentOptimizer`. + +The module consists of four sub-modules, driven externally through a single entry point `AgentOptimizer.optimize`: + +| Sub-module | Responsibility | +|---|---| +| **Optimization Algorithm** | Reflection-evaluation-retention loop; currently built-in [GEPA](https://github.com/gepa-ai/gepa) (Genetic-Evolutionary Pareto, MIT License), extensible to other algorithms via `OPTIMIZER_REGISTRY` | +| **Evaluation Bridge** | Reuses `AgentEvaluator`, allowing the optimization process to share the same `EvalSet` and metric configuration with daily regression | +| **Prompt Management** | `TargetPrompt` unifies prompt field read/write; supports two sources: local files (path) and arbitrary backends (callback) | +| **Runtime Orchestration** | Resource scheduling, stoppers, atomic artifact persistence, SIGINT signal safety | + +`AgentOptimizer` redefines "prompt tuning" as an engineering problem that is **bounded, reproducible, and auditable**: + +| Dimension | Expression | +|---|---| +| Optimization Objective | `evaluate.metrics[]` — a set of numerical, repeatable evaluation metrics | +| Decision Variables | Prompt fields registered with `TargetPrompt` (one or more) | +| Search Process | Reflection-evaluation-retention loop driven by reflection LM (see [§5](#5-how-gepa-works) for details) | +| Termination Conditions | 6 built-in stoppers + user-defined stoppers (see [§4.7](#47) for details) | +| Artifacts | `OptimizeResult` object + `runs//` full audit directory (see [§8](#8-artifacts-and-directory-conventions) for details) | + +> **Prerequisite Reading**: [Agent Evaluation](evaluation.md) — Optimization is built on top of evaluation; this document assumes the reader understands the basic concepts of `EvalSet` and `metric`. + +--- + +## 1 What Is This / What Problem Does It Solve + +### 1.1 Problems Solved + +After agent applications enter business-critical paths, prompts (including all natural language text that drives agent behavior such as skills, rules, etc.) are among the most expensive assets to iterate: manual tuning relies on engineers' ability to summarize failure cases, and regression risks amplify rapidly after scaling; coupling between prompt fields on multi-sub-agent chains makes single-field optimization meaningless; model upgrades, tool changes, and scenario expansion all cause "yesterday's optimal" prompts to fail today. + +The `AgentOptimizer` module completely **engineers this iterative process**: + +- **Explicit optimization objectives** — crystallizes "what counts as good" into a numerical contract of metric + threshold, shareable across evaluation, optimization, and CI/CD +- **Algorithmic search process** — reflection-evaluation-retention loop replaces manual trial and error; process is replayable, results are comparable +- **Multi-prompt joint optimization** — supports simultaneous optimization of multiple fields (e.g., router + worker + summarizer instructions, CLAUDE.md + SKILL.md), and uses GEPA's merge mechanism for cross-field search +- **Auditable runtime process** — each round's reflection input, candidate changes, evaluation scores, acceptance/rejection reasons are all persisted to `runs//`, supporting post-hoc traceability +- **Controllable and rollbackable results** — `update_source` determines whether to write back to source prompts; `TargetPrompt` provides atomic writes and failure rollback; half-written disk writes or secondary SIGINT interrupts will not corrupt source files + +### 1.2 Relationship with the Evaluation Module + +`AgentEvaluator` and `AgentOptimizer` constitute the two ends of the **evaluation-optimization closed loop**: + +| Module | Role | Output | +|---|---|---| +| `AgentEvaluator` ([evaluation.md](evaluation.md)) | Measures current prompt quality | Pass/fail per case + each metric score | +| `AgentOptimizer` (this document) | Searches for better prompts based on measurement results | Optimal prompt + full optimization history | + +The two share the same `EvalSet`, the same metric configuration, and the same `call_agent`. One set of assets supports both daily regression (pytest running `AgentEvaluator`) and periodic optimization (night window running `AgentOptimizer`, see [§4.6 CI Closed Loop](#46)). + +### 1.3 Applicable Boundaries + +The effectiveness of `AgentOptimizer` depends on three prerequisites: + +1. **Evaluation signals are sufficiently stable**. When the variance of the scoring itself is greater than the improvement brought by prompt rewriting, the optimization direction is unreliable. It is recommended to first run `AgentEvaluator` with `num_runs=3` to observe metric cross-run consistency before starting optimization. +2. **Budget matches the search space**. A typical small-scale optimization is on the order of `max_metric_calls=30~60` (one case-level evaluation counts as one metric_call), 5~20 reflection LM calls, running 1~10 minutes, consuming tens to hundreds of dollars (see [§6 Cost and Concurrency](#6-cost-and-concurrency) for details). When the budget is significantly lower than this level, you should first complete baseline tuning on `AgentEvaluator`. +3. **Prompt has optimizable semantic structure**. Prompts with fewer than 20 characters hardcoded or used only for placeholder concatenation have too narrow a search space; GEPA reflection degenerates into synonym rewriting in this scenario. + +For scenarios not within the above prerequisites, you should prioritize using [`AgentEvaluator`](evaluation.md) for continuous observation rather than starting optimization. + +## 2 5-Minute Quickstart + +Complete code and data: [`examples/optimization/quickstart/`](../../../examples/optimization/quickstart/). + +### 2.1 Example Task + +The agent in this example is an **elementary school arithmetic word problem solver**: it receives arithmetic problems described in natural language (e.g., "Xiao Ming bought 4 apples in the morning and 7 more apples in the afternoon. How many apples does he have in total?"), and outputs a numerical answer with units (e.g., "Answer: 11 apples"). + +The agent behavior is driven by two prompt files together, which are the optimization targets for this session: + +| Optimization Target | Path | Role in Agent | +|---|---|---| +| **system_prompt** | `agent/prompts/system.md` | Role and response style definition (e.g., "You are a math teaching assistant, answer in clear Chinese") | +| **skill** | `agent/prompts/skill.md` | Problem-solving methodology (e.g., "First identify the problem type → set up equation → calculate → write answer with units") | + +Evaluation scores from two dimensions simultaneously, both must pass for the agent to pass: + +| Evaluation Metric | Type | Threshold | Scoring Method | +|---|---|---|---| +| `final_response_avg_score` | Text matching | 1.0 | Agent output must **contain** the reference text (e.g., "Answer: 11 apples"), case-insensitive | +| `llm_rubric_response` | LLM judge | 0.66 | Independent LLM scores according to three rubrics and takes the mean: ① answer value matches reference ② reasoning steps are clear ③ answer has correct units | + +Dataset size: training set 5 cases, validation set 3 cases. + +### 2.2 Prepare Environment + +```bash +pip install "trpc-agent-py[optimize]" + +export TRPC_AGENT_API_KEY="" +export TRPC_AGENT_BASE_URL="" +export TRPC_AGENT_MODEL_NAME="" +``` + +The `[optimize]` extra includes `gepa` (reflection algorithm implementation) and `rich` (terminal progress panel). + +### 2.3 Directory Structure + +```text +examples/optimization/quickstart/ +├── agent/ +│ ├── agent.py # Defines create_agent() factory function +│ ├── config.py # Model / credentials read from environment variables +│ └── prompts/ +│ ├── system.md # Baseline system prompt (to be optimized) +│ └── skill.md # Baseline skill document (to be optimized) +├── train.evalset.json # 5 training cases (source of reflection minibatch) +├── val.evalset.json # 3 validation cases (full evaluation each round, decides whether candidate is accepted) +├── optimizer.json # Algorithm + metric configuration +└── run_optimization.py # Entry script +``` + +> Training and validation sets must be different files; the framework validates at startup that paths do not overlap. + +### 2.4 Core Code + +`run_optimization.py` consists of three segments, corresponding to the three core abstractions exposed by the optimizer. + +**Segment 1: `call_agent` — Business Bridge Function** (see [§3.4](#34-call_agent) for details) + +The signature is fixed as `async def(query: str) -> str`. The framework drives the agent to complete single inference through it; agents of any form (`LlmAgent`, HTTP service, subprocess CLI, etc.) are all accessed through this layer of bridging. + +```python +async def call_agent(query: str) -> str: + # Re-read prompt files each time → GEPA writes new candidates and they take effect immediately + root_agent = create_agent() + session_service = InMemorySessionService() + runner = Runner(app_name=APP_NAME, agent=root_agent, + session_service=session_service) + # ... send user_content, collect is_final_response events + return final_text.strip() +``` + +**Segment 2: `TargetPrompt` — Optimization Target Declaration** (see [§3.3](#33-targetprompt) for details) + +Registers which prompt fields will be read/written by the optimizer. Each field corresponds to a local file (`add_path`) or a pair of async read/write callbacks (`add_callback`, used for arbitrary backends like remote KV). + +```python +target = ( + TargetPrompt() + .add_path("system_prompt", str(SYSTEM_PROMPT_PATH)) + .add_path("skill", str(SKILL_PATH)) +) +``` + +**Segment 3: `AgentOptimizer.optimize` — Optimizer Invocation** (full parameters see [§7.1](#71-agentoptimizeroptimize-parameter-table)) + +```python +await AgentOptimizer.optimize( + config_path=str(CONFIG_PATH), + call_agent=call_agent, + target_prompt=target, + train_dataset_path=str(TRAIN_PATH), + validation_dataset_path=str(VAL_PATH), + output_dir=str(RUNS_DIR / timestamp), + update_source=False, + verbose=1, +) +``` + +| Parameter | Description | +|---|---| +| `config_path` | `optimizer.json`, defines metric / algorithm / stop conditions | +| `output_dir` | Artifact directory; created automatically if it doesn't exist, recommended to use timestamp subdirectory | +| `update_source` | `False` only produces `best_prompts/`; `True` writes back to source files after successful optimization (CI scenario, see [§4.6](#46)) | +| `verbose` | `0` silent / `1` Rich progress panel / `2` plus gepa diagnostic logs | + +### 2.5 Configuration File `optimizer.json` + +The configuration is divided into two sections: `evaluate` (evaluation, same source as the evaluation module) + `optimize` (optimizer-specific). + +```json +{ + "evaluate": { + "metrics": [ + { + "metric_name": "final_response_avg_score", + "threshold": 1.0, + "criterion": { + "final_response": {"text": {"match": "contains", "case_insensitive": true}} + } + }, + { + "metric_name": "llm_rubric_response", + "threshold": 0.66, + "criterion": { + "llm_judge": { + "judge_model": {"model_name": "...", "base_url": "...", "api_key": "..."}, + "rubrics": [ + {"id": "numeric_correct", "content": {"text": "Answer value matches reference"}, "type": "FINAL_RESPONSE_QUALITY"}, + {"id": "reasoning_clear", "content": {"text": "Reasoning steps are clear"}, "type": "FINAL_RESPONSE_QUALITY"}, + {"id": "units_present", "content": {"text": "Answer has correct units"}, "type": "FINAL_RESPONSE_QUALITY"} + ] + } + } + } + ], + "num_runs": 1 + }, + "optimize": { + "eval_case_parallelism": 2, + "stop": {"required_metrics": "all"}, + "algorithm": { + "name": "gepa_reflective", + "seed": 42, + "reflection_lm": {"model_name": "...", "base_url": "...", "api_key": "..."}, + "candidate_selection_strategy": "pareto", + "module_selector": "round_robin", + "reflection_minibatch_size": 3, + "skip_perfect_score": false, + "max_metric_calls": 60, + "max_iterations_without_improvement": 8 + } + } +} +``` + +Key concepts used in this example: + +| Concept | Location in Config | One-Line Explanation | See Also | +|---|---|---|---| +| **metric** | `evaluate.metrics[]` | List of evaluation metrics; multiple can be stacked, each scored independently | [§4.5](#45) | +| **LLM judge** | `criterion.llm_judge` | LLM judge that scores according to rubrics; serves `llm_rubric_response` in this example | [§4.5](#45) | +| **stop.required_metrics** | `optimize.stop.required_metrics` | Framework-level stop: which metrics must all reach threshold before stopping | [§7.3.5](#735-optimizestop-section) | +| **reflection_lm** | `optimize.algorithm.reflection_lm` | Reflection LLM that reviews failed cases each round and generates new candidate prompts | [§3.8](#38-reflection-lm) / [§6.5](#65-reflection-lm-selection-suggestions-table) | +| **candidate_selection_strategy** | `optimize.algorithm` | Which candidate to pick as reflection parent each round | [§7.3.3](#733-optimizealgorithm-section) | +| **module_selector** | `optimize.algorithm` | Which field to rewrite each round in multi-field optimization | [§4.3](#43) | +| **reflection_minibatch_size** | `optimize.algorithm` | How many cases to sample from train each round for reflection | [§5](#5-how-gepa-works) | +| **stopper** | `optimize.algorithm.max_*` / `timeout_seconds` / `score_threshold` | Algorithm-level stop conditions, at least one must be set | [§4.7](#47) / [§7.3.3](#733-optimizealgorithm-section) | + +See [§7.3](#73-optimizerjson-configuration-items-table) for the complete field reference. + +### 2.6 Run + +```bash +python examples/optimization/quickstart/run_optimization.py +``` + +The terminal outputs in order: baseline evaluation scores → acceptance/rejection records for each round's reflection → final summary. Completes in 1~3 minutes under small-scale configuration. + +![Quickstart Terminal Output Example](../assets/imgs/optimization_quickstart.png) + +```text +runs// +├── result.json # Complete run record (OptimizeResult serialized) +├── summary.txt # Human-readable overview (read this first) +├── run.log # Single-line status +├── config.snapshot.json # Snapshot copy of input configuration +├── rounds/round_NNN.json # Each round's RoundRecord +├── baseline_prompts/.md # Pre-optimization snapshot +└── best_prompts/.md # Best candidate after optimization (only if SUCCEEDED) +``` + +Key lines in `summary.txt`: + +```text +Optimization complete | status=SUCCEEDED | algorithm=gepa_reflective +pass_rate : 0.5000 -> 0.8500 (+0.3500, improved) +rounds : 3 accepted / 7 total +duration : 124.31s +stop_reason : required_metrics_passing +update_source : false +``` + +> **What is pass_rate?** +> +> pass_rate measures: **what proportion of cases your agent "got right" on the validation set**. +> +> --- +> +> **Step 1: Each metric independently determines pass/fail** +> +> Each metric has its own threshold. Score ≥ threshold means pass; otherwise fail. +> +> **Step 2: A case passes only when ALL metrics pass** +> +> Think of it like an exam with multiple subjects — you must pass every subject to pass overall. Failing any single subject means the whole case fails. +> +> **Step 3: pass_rate = number of passing cases ÷ total cases** +> +> --- +> +> **Walkthrough example**: Suppose the validation set has 4 cases, with 3 metrics configured: +> +> | | metric_A (threshold 0.8) | metric_B (threshold 0.6) | metric_C (threshold 1.0) | Does this case pass? | +> | --- | --- | --- | --- | --- | +> | case_1 | score 0.9 ✅ | score 0.7 ✅ | score 1.0 ✅ | **Pass** (all 3 met) | +> | case_2 | score 0.85 ✅ | score 0.4 ❌ | score 1.0 ✅ | **Fail** (metric_B not met) | +> | case_3 | score 0.6 ❌ | score 0.8 ✅ | score 0.0 ❌ | **Fail** (metric_A & C not met) | +> | case_4 | score 0.95 ✅ | score 0.9 ✅ | score 1.0 ✅ | **Pass** (all 3 met) | +> +> 2 passed out of 4 total: +> +> ``` +> pass_rate = 2 / 4 = 0.5 +> ``` +> +> --- +> +> **Back to the summary.txt above**: +> +> ``` +> pass_rate : 0.5000 -> 0.8500 (+0.3500, improved) +> ``` +> +> This means: before optimization the agent could only get half the cases right; after optimization it gets 85% right. An improvement of 35 percentage points. +> +> **Three related fields**: +> +> | Field | Meaning | +> | --- | --- | +> | `baseline_pass_rate` | Pass rate before optimization (scored with the initial prompt) | +> | `best_pass_rate` | Highest pass rate found during optimization | +> | `pass_rate_improvement` | `best - baseline`, the improvement gained from this optimization run | + +See [§8 Artifacts and Directory Conventions](#8-artifacts-and-directory-conventions) for the complete meaning of each field. + +### 2.7 Next Steps + +| Your Next Question | Jump to Section | +|---|---| +| What exactly are these API concepts? | [§3 Core Concepts](#3-core-concepts) | +| My agent isn't this kind of local LlmAgent, how do I integrate? | [§4 Your Scenario → How to Integrate](#4-your-scenario--how-to-integrate) | +| What exactly does each step of the reflection-evaluation-retention loop do? | [§5 How GEPA Works](#5-how-gepa-works) | +| Want to estimate LLM call costs / adjust concurrency parameters? | [§6 Cost and Concurrency](#6-cost-and-concurrency) | +| Want to directly look up parameters / configuration items? | [§7 Complete API Reference](#7-complete-api-reference) | + +## 3 Core Concepts + +> This section uses 8 concepts to establish a "mental model" of the optimization module. Each concept starts from "what does it correspond to in your work" rather than from type signatures. The introduction order is consistent with the appearance order of the three code segments in [§2.4 Core Code](#24-core-code). + +### 3.1 Module Overall Data Flow + +The optimization module's work loop: the user inputs 4 types of assets, and the module produces 2 types of results in the reflection-evaluation-retention loop. + +```text + +---> Evaluate candidate + | | + call_agent ---+ | v + | | Reflect on failures + optimizer.json ---+ | | + | | v ---> OptimizeResult + +------>| Write new candidate + runs// + TargetPrompt ---+ | | + | | v + EvalSet x 2 ---+ | Accept new best? + | Y:keep / N:drop + | | + +---------+ +``` + +Roles of the four inputs: + +| Input | Form | Role in the Loop | +| --- | --- | --- | +| `call_agent` | `async (str) -> str` | Passes query to business agent; optimizer samples behavior through this | +| `optimizer.json` | JSON configuration | Defines evaluation metrics (`evaluate.metrics`) and algorithm parameters (`optimize.algorithm`) | +| `TargetPrompt` | Multi-field prompt registration table | Declares which prompt files / remote configuration entries are optimization targets | +| `EvalSet × 2` | Two evalsets | Training set for reflection LM to see failure cases, validation set for scoring / early stop determination | + +Destinations of the two outputs: + +| Output | Form | Typical Use | +| --- | --- | --- | +| `OptimizeResult` | In-memory object returned by `optimize()` | Programmatic reading (baseline / best / each round details) | +| `runs//` | Audit directory | Manual review, CI parsing, re-run (see [§8](#8-artifacts-and-directory-conventions) for details) | + +### 3.2 call_agent + +**One sentence**: The "universal plug" for your business agent. + +**Why needed**: Your agent might be a local `LlmAgent`, might be a deployed HTTP service, might be a black-box CLI like `claude` / `codex`. The module cannot write adapters for every form; you only need to wrap "given a query → get the agent's final response" into an async function, and the module drives the agent to run evaluations through it. + +**How to use**: + +```python +async def call_agent(query: str) -> str: + # Your implementation: call local agent / HTTP service / subprocess CLI, all fine + # Key point: re-read prompt files each time (so GEPA's new candidates take effect immediately) + root_agent = create_agent() + runner = Runner(...) + return await run_and_collect_final_response(runner, query) +``` + +The signature is fixed as `async (str) -> str`, cannot have more parameters nor be synchronous. + +**When the framework calls it**: + +| Timing | Frequency | +|---|---| +| Baseline evaluation | Each val case × `num_runs` | +| Each round's minibatch evaluation | Each sampled case 1 time | +| Each round's candidate validation set evaluation | Each val case × `num_runs` | + +### 3.3 TargetPrompt + +**One sentence**: Tells the module "which prompt files are to be optimized", equivalent to an **optimization target registration table**. + +**Why needed**: In agent projects, prompts are usually scattered across multiple files or even multiple backends (system.md / skill.md / also placed in QCS versions); the module needs to know: **when a new candidate is reflected, where should it be written, and where should it read from when reading baseline**. `TargetPrompt` is this "address book". + +**How to use**: + +```python +from trpc_agent_sdk.evaluation import TargetPrompt + +target = ( + TargetPrompt() + .add_path("system_prompt", "agent/prompts/system.md") # File type + .add_path("skill", "agent/prompts/skill.md") # File type + .add_callback("rule", # Callback type (remote KV) + read=load_rule_from_kv, + write=save_rule_to_kv) +) +``` + +Each field `name` (e.g., `"system_prompt"`) will become, after optimization ends: + +- `result.best_prompts["system_prompt"]` — programmatic reading of optimal prompt +- `runs//best_prompts/system_prompt.md` — human reading of optimal prompt +- Elements in `RoundRecord.optimized_field_names` — see which field was changed each round + +**Two types of sources**: + +| Source | Applicable When | What the Framework Does | +|---|---|---| +| `add_path(name, path)` | Prompt is in local file | Write to disk using tmp + `os.replace` atomic write; multi-field failure rolls back source files | +| `add_callback(name, *, read, write)` | Prompt is in remote configuration center / database / git, etc., any backend | Calls your `read` / `write` async functions; atomicity is guaranteed by you | + +See [§7.2](#72-targetprompt-api-table) for the complete API. + +### 3.4 AgentOptimizer + +**One sentence**: The module's "power button". + +**Why needed**: You wouldn't want to manually write the whole process of "read config → validate inputs → run reflection loop → persist to disk → assemble result"; `AgentOptimizer` encapsulates this process into one call—you give it **inputs**, it returns **results**. + +**How to use**: + +```python +from trpc_agent_sdk.evaluation import AgentOptimizer + +result = await AgentOptimizer.optimize( + config_path="optimizer.json", + call_agent=call_agent, + target_prompt=target, + train_dataset_path="train.evalset.json", + validation_dataset_path="val.evalset.json", + output_dir="runs/2026-05-19T17-00-00", +) +print(result.best_pass_rate) +``` + +This module has only this one public entry point, **no other way to start optimization**. + +**What it does**: + +1. Loads and validates `optimizer.json` (throws error before running if schema is wrong) +2. Validates `call_agent` is async function / `target_prompt` has at least one registered field / training set ≠ validation set +3. Runs reflection-evaluation-retention loop +4. Persists artifacts to `output_dir/` +5. Returns an `OptimizeResult` object + +`optimize` has 11 keyword-only parameters in total; the 6 commonly used ones are in [§2.4](#24-core-code), all parameters see [§7.1](#71-agentoptimizeroptimize-parameter-table). + +**`update_source` decision table** (key parameter shared by all §4.x scenarios): Determines whether to **write back** the optimal candidate to the source prompt files registered in `TargetPrompt` after successful optimization— + +| `update_source` | What to do after success | Effective Path | Applicable Scenario | +|---|---|---|---| +| `False` (default) | Only write the optimal candidate to `output_dir/best_prompts/` | You **manually** review → copy to online prompt file → takes effect on next call | Grayscale deployment, requires manual review, don't want optimizer to directly modify online files | +| `True` | Directly **overwrite** source prompt files with the optimal candidate | Business next call **immediately** uses the new prompt | Automated closed loop (e.g., night optimization task, see [§4.6 CI Closed Loop](#46)) | + +Regardless of which you choose, the business side requires **zero restart, zero code changes**—the way to perceive prompt changes is always "re-read file on next call". + +> Safety guarantee of `update_source=True`: Overwrite uses tmp + `os.replace` atomic write; if optimization is interrupted midway or by SIGINT, the source prompt file **will not be half-written**, preserving original content (see [§8.3 Atomic Disk Persistence](#83-atomic-disk-persistence-guarantee) for details). + +### 3.5 optimizer.json + +**One sentence**: A configuration file that tells the module "what counts as good" and "how to search". + +**Why needed**: Metric thresholds, minibatch size, reflection LM configuration, stop conditions... if these parameters are scattered in code, you need to modify code every time you run an experiment. After centralizing to one JSON file, tuning parameters = modify JSON, and reproducibility is also better (a copy of `config.snapshot.json` will be saved in the artifacts). + +**What it looks like**: [§2.5](#25-configuration-file-optimizerjson) already showed the complete example. Structurally divided into two sections: + +```text +{ + "evaluate": { ... }, # Same schema as AgentEvaluator: metric list + num_runs + "optimize": { + "eval_case_parallelism": 2, + "stop": { # Framework-level stop: which metrics must reach threshold + "required_metrics": "all" + }, + "algorithm": { # Algorithm-specific: reflection_lm / minibatch / 6 types of stoppers + "name": "gepa_reflective", + ... + } + } +} +``` + +**Division of labor between the two sections**: + +- `evaluate` section: **completely reuses** the evaluation module's schema. Metric configurations you wrote for evaluation projects can be directly copied over +- `optimize` section: **optimizer-specific**. Among them, `algorithm.name` is the algorithm selector; currently the only optional value is `"gepa_reflective"`, will be extended by [§9.2 Registering New Algorithms](#92) when new algorithms are added in the future + +See [§7.3](#73-optimizerjson-configuration-items-table) for the complete field table. + +### 3.6 EvalSet / EvalCase + +**One sentence**: Training set + validation set, format identical to the evaluation module. + +**Why need two separate files**: + +- **Training set**: The module randomly **samples** a few cases from it each round (`reflection_minibatch_size`, default lets gepa decide) for the reflection LM to see failure cases → used to "find improvement directions" +- **Validation set**: After each new candidate is generated, **run fully** on it for scoring → used to "verify whether the candidate is actually better" + +**Why must they be different files**: The training set determines what the reflection LM sees, the validation set determines whether a candidate is accepted. If the two overlap, it becomes "using exam questions for practice, then using exam questions for grading"—the resulting best_pass_rate is not credible. The framework validates at startup by comparing paths (`os.path.normpath(os.path.abspath(...))`) to defend against this, and directly throws `ValueError` if they overlap. + +See [Evaluation Set Writing Guide](evaluation.md#evaluation-set-evalset-writing-guide) for format and writing guidelines. + +### 3.7 OptimizeResult + +**One sentence**: The "complete output" after one optimization run, both the return value of `optimize()` and the content of `runs//result.json`. + +**Why needed**: After running optimization, you care most about three things—success or not / how much improvement / what is the optimal prompt. `OptimizeResult` packages them: + +```python +result = await AgentOptimizer.optimize(...) + +# 1. Success or not +if result.status == "SUCCEEDED": + ... + +# 2. How much improvement +print(f"{result.baseline_pass_rate:.2%} → {result.best_pass_rate:.2%}, " + f"+{result.pass_rate_improvement:.2%}") + +# 3. What is the optimal prompt +new_system_prompt = result.best_prompts["system_prompt"] +new_skill = result.best_prompts["skill"] +``` + +It also carries process data (what happened each round, reflection LM call count, total duration, etc.) for post-hoc analysis. + +**The 6 most frequently viewed fields**: + +| Field | Type | Meaning | +|---|---|---| +| `status` | `"SUCCEEDED"` / `"FAILED"` / `"CANCELED"` | Final state | +| `baseline_pass_rate` / `best_pass_rate` | `float` | Pass rate before / after optimization | +| `pass_rate_improvement` | `float` | Difference between the two | +| `best_prompts` | `dict[str, str]` | Field name → optimal prompt text | +| `rounds` | `list[RoundRecord]` | Each round's record | +| `stop_reason` | `Literal[...]` or `None` | Which stopper triggered the stop | + +See [§7.4](#74-optimizeresult--roundrecord-field-table) for all 22 fields (including `RoundRecord`). + +### 3.8 Reflection LM + +**One sentence**: The LLM used internally by the module, which receives a set of failure cases each round and outputs improved prompt candidates; it is a separate configuration from the business LM used by your agent. + +Configured in the `optimizer.json::optimize.algorithm.reflection_lm` section, type is `OptimizeModelOptions`: + +```json +"reflection_lm": { + "model_name": "gpt-4o", + "base_url": "https://api.openai.com/v1", + "api_key": "sk-...", + "generation_config": {"temperature": 0.6, "max_tokens": 4096} +} +``` + +See [§6.5](#65-reflection-lm-selection-suggestions-table) for model selection suggestions; see [§7.3.3](#733-optimizealgorithm-section) for complete fields. + +## 4 Your Scenario → How to Integrate + +| Your Situation | Section | Corresponding Example | +|---|---|---| +| Agent is an online HTTP service (FastAPI / Gin / self-developed interface) | [§4.1](#41) | `http_service` | +| Agent is a subprocess / command-line tool (`claude` / `codex` / internal CLI) | [§4.2](#42) | `blackbox_cli` | +| Agent is a multi-sub-agent chain (multiple sub-agents collaborate to complete one response), want to optimize each sub-agent's prompt simultaneously | [§4.3](#43) | `multi_agent_pipeline` | +| Prompts are not in local files, stored in remote KV / configuration center / database / Git, etc., any backend | [§4.4](#44) | `remote_prompt_store` | +| Single evaluation metric is insufficient, need to run multiple evaluation metrics simultaneously (e.g., answer accuracy + hallucination rate + style compliance rate) and fuse into a total score | [§4.5](#45) | `multi_metric_with_judges` | +| Want to integrate CI closed loop: run evaluation gate on PR, run optimization in night window and automatically write back new prompts | [§4.6](#46) | `ci_integration` | +| Optimization task has hard constraints (e.g., must complete within 1-hour window / cumulative calls not exceeding N / stop after consecutive no-improvement) | [§4.7](#47) | `slo_runtime_control` | +| Can already run through the basic process, want to further improve results (adjust GEPA candidate selection / Pareto frontier / cross-field fusion) | [§4.8](#48) | `advanced_strategies` | +| Other common extensions (connect Grafana / WandB, etc. for monitoring, custom stop strategy, use your own optimization algorithm) | [§4.9](#49) | (Multiple examples combined) | + +### 4.1 My Agent is an HTTP Service, How to Integrate? {#41} + +**Your situation**: The business agent is already online as an independent service (FastAPI / Gin / self-developed framework are all acceptable), hoping to perform automatic optimization on its prompts—but the service runs long-term and cannot stop, service implementation details are a black box to the optimizer, and prompts are usually injected in file form. + +**Integration model**: The optimizer accesses as a **pure client**, with only **one coupling point** with the service process—the prompt files on disk. + +```text ++-------------------+ HTTP request + query +-------------------+ +| AgentOptimizer | --------------------------------> | HTTP agent | +| (optimizer) | <--------- text response -------- | (no code change) | ++---------+---------+ +---------+---------+ + | ^ + | write new prompt candidate | Each request + v | re-reads prompt + +------------------------------------------------------------+ + | prompt files (on disk) | + +------------------------------------------------------------+ +``` + +The service process **does not need any code changes**, only needs to satisfy one convention: **re-read prompt files before processing each request**—so that the new candidate written by the optimizer takes effect on the next request. + +**Integration in 3 steps**: + +**Step 1: Register `TargetPrompt` on the prompt files read by the HTTP service** + +```python +target = TargetPrompt().add_path("system_prompt", "service/prompts/system.md") +``` + +The second parameter of `add_path` must be **the exact file path that the service process actually reads** (not an arbitrary copy), otherwise the new candidate written by the optimizer will not be perceived by the service. + +**Step 2: Write `call_agent` as an HTTP client to the service** + +```python +async def call_agent(query: str) -> str: + async with httpx.AsyncClient(timeout=120.0) as client: + resp = await client.post("http://my-agent-service/chat", + json={"query": query}) + resp.raise_for_status() + return resp.json()["final_text"] +``` + +Modify the `json=...` field according to the actual interface payload schema of the business; adjust `timeout` according to the business's first inference latency (example default 120s). + +**Step 3: Call `AgentOptimizer.optimize`** + +```python +await AgentOptimizer.optimize( + config_path="optimizer.json", + call_agent=call_agent, + target_prompt=target, + train_dataset_path="train.evalset.json", + validation_dataset_path="val.evalset.json", + output_dir=f"runs/{timestamp}", + update_source=False, # Decision table see [§3.4](#34-agentoptimizer) +) +``` + +**Pre-integration checklist**: + +| Check Item | Description | +|---|---| +| Does the service re-read prompt files on each request? | No → New candidates written by optimizer won't be seen by the service, optimization is ineffective. Need to add re-read logic in the handler | +| Does the optimizer process have write permission to prompt files? | No → Optimizer cannot persist new candidates | +| Are the prompt file paths seen by the service and the optimizer consistent? | Especially need to confirm in containerized deployment (mount path / symlink) | +| What is the service's 5xx behavior? | The service should not silently retry internally—this would mask the real failure rate, letting the optimizer see a false "high score" | + +**→ Complete example**: [`examples/optimization/http_service/`](../../../examples/optimization/http_service/) +- `service/server.py` — Demonstrates FastAPI service with prompt hot-loading (`/chat` rebuilds agent and re-reads `system.md` each time), can be used as a reference for business service transformation +- `run_optimization.py` — Client optimizer entry, includes pre-start service health check (fail-fast) + +### 4.2 My Agent is an External Command-Line Tool (CLI), Optimizer Cannot Get Its Code {#42} + +**Your situation**: The business agent is an external executable program—`claude` / `codex` / self-developed CLI, etc. Its source code, internally used LLM client, and runtime language are **completely black boxes** to the optimizer, but it reads several prompt files from a working directory at startup (typically `CLAUDE.md` + `.claude/skills//SKILL.md`). You hope to optimize these prompt files without modifying the CLI code or binding to any of its internal dependencies. + +**Integration model**: The optimizer calls the CLI through **subprocess**, and the **only coupling point** with the CLI is still the prompt files on disk—this is the same structure as §4.1's HTTP service, the difference is only replacing "HTTP request" with "starting a subprocess". + +```text ++-------------------+ start subprocess + pass query +-------------------+ +| AgentOptimizer | --------------------------------> | External CLI | +| (optimizer) | <--------- stdout text ---------- | (no code change) | ++---------+---------+ +---------+---------+ + | ^ + | write new prompt candidate | Each startup + v | auto-reads + +------------------------------------------------------------+ + | prompt files (on disk) | + +------------------------------------------------------------+ +``` + +The CLI binary itself **does not need any modifications**, only needs to satisfy: **it loads prompt files from the specified directory on each startup** (most CLI tools are designed this way). + +**Integration in 3 steps**: + +**Step 1: Register `TargetPrompt` on the prompt files read by the CLI (use `add_path` multiple times for multiple files)** + +```python +target = ( + TargetPrompt() + .add_path("claude_md", "workspace/CLAUDE.md") + .add_path("skill_md", "workspace/.claude/skills/city-info/SKILL.md") +) +``` + +Each `add_path` registers one independent field; GEPA treats each field as an independently optimizable module, can optimize separately/jointly (see §3.7, §4.3 for details). + +**Step 2: Wrap subprocess call + stdout normalization into `call_agent`** + +```python +async def call_agent(query: str) -> str: + proc = await asyncio.create_subprocess_exec( + "trpc-claudecode", "--print", + "--add-dir", str(WORKSPACE_DIR), # CLI loads prompt files from here + "--dangerously-skip-permissions", + query, # Pass query as argv, avoid shell escaping + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_build_cli_env(), # Environment variables expected by business's own CLI + ) + stdout_b, stderr_b = await asyncio.wait_for( + proc.communicate(), timeout=90.0, # Prevent single CLI from hanging + ) + if proc.returncode != 0: + raise RuntimeError(f"CLI exited {proc.returncode}: {stderr_b[:400]!r}") + return _normalize_response(stdout_b.decode("utf-8", "replace")) +``` + +`call_agent` still has the standard signature `async (query: str) -> str` from §3.1; to the optimizer main loop, this `call_agent` is no different from "calling local LLM". `_build_cli_env` / `_normalize_response` are helper functions implemented by the business according to their CLI's characteristics (the former modifies/supplements environment variables to the form expected by the CLI, the latter normalizes CLI stdout into a stable string comparable for evaluation)—this framework does not prescribe their form, implement as needed. + +**Step 3: Run once to confirm baseline works, then hand over to GEPA reflection optimization** + +```python +await AgentOptimizer.optimize( + config_path="optimizer.json", + call_agent=call_agent, + target_prompt=target, + train_dataset_path="train.evalset.json", + validation_dataset_path="val.evalset.json", + output_dir="runs//", + update_source=False, +) +``` + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Does the CLI re-read prompt files on each startup? | No → New candidates written by optimizer won't take effect; evaluation between candidates is equivalent to running the same baseline | +| Does the CLI support passing query through argv / stdin / `--query xxx`? | No → Integration is not feasible (need to add this entry point to CLI first) | +| Is the CLI's average single-run latency known? | No → Cannot reasonably set `CLI_TIMEOUT_SEC` and `max_metric_calls` | +| Does the CLI process pollute shared disk state (other than prompt files)? | Yes → Evaluation is not reproducible; need `eval_case_parallelism=1` or independent workspace for each case | + +**→ Complete example**: [`examples/optimization/blackbox_cli/`](../../../examples/optimization/blackbox_cli/) +- `agent/call_agent.py` — Subprocess call + environment variable adaptation + stdout normalization engineering implementation, can be used as a starting point for integrating your own CLI +- `run_optimization.py` — Standard entry for dual-field (`CLAUDE.md` + `SKILL.md`) `TargetPrompt` + +### 4.3 My Agent is a Multi-Sub-Agent Chain, Want to Optimize Each Sub-Agent's Prompt Simultaneously {#43} + +**Your situation**: The business side has already orchestrated a multi-sub-agent collaboration chain. Each sub-agent has its own system prompt, and there are implicit contracts between fields (the output form of upstream sub-agent must match downstream expectations). Common symptoms during manual iteration are **"fixing A shows effect, but drags down B"**. You hope to **jointly optimize** prompts for all sub-agents, so that end-to-end metrics improve. + +**Integration model**: Register each sub-agent's prompt file as an **independent field** of `TargetPrompt`—GEPA treats each field as an independently optimizable module (component), selects 1 or more fields to write back each round according to `module_selector`, and the optimizer only looks at the end-to-end metric score as feedback. The chain code requires **zero modifications**; each sub-agent just needs to re-read its own prompt file each time it is called. + +```text ++-----------------------------+ select 1 field each round +---------------------+ +| AgentOptimizer | --------------------------> | prompt files | +| (multi-field TargetPrompt) | write back new candidate | (each sub-agent | +| | | has 1 file) | ++--------------+--------------+ +----------+----------+ + ^ | + | End-to-end metric score | Each call + | | re-reads prompt + | v + | +-----------------------------------------+ + +------------- | call_agent(query) | + | = Your multi-sub-agent chain | + | call entry | + | (sub-agent A → sub-agent B → ...) | + +-----------------------------------------+ +``` + +**Integration in 3 steps**: + +**Step 1: Register each sub-agent's prompt file as an independent field** + +```python +target = ( + TargetPrompt() + .add_path("agent_a", ".md") + .add_path("agent_b", ".md") + # ... one add_path per sub-agent +) +``` + +The key is the identifier of this field in reflection prompts / artifact filenames; it just needs to be readable by the business. + +**Step 2: Wrap the entire chain call into `call_agent`, and ensure sub-agents re-read prompts each time** + +```python +async def call_agent(query: str) -> str: + return await invoke_pipeline(query) # Your existing chain entry +``` + +Key constraint inside `invoke_pipeline`: **each sub-agent must re-read its own prompt file each time it is called**, otherwise new candidates written by the optimizer will not take effect. + +**Step 3: Turn on multi-field related switches in `optimizer.json`** + +```jsonc +{ + "optimize": { + "algorithm": { + "module_selector": "round_robin", // Select 1 field per round in rotation, convenient for attribution + "use_merge": true, // Actively fuse after accumulating several single-field improvements + "max_merge_invocations": 3, + "reflection_history_top_k": 3 // Recommended to increase when multi-field rotation (default 2) + } + } +} +``` + +See [§7 Complete API Reference](#7-complete-api-reference) for the complete semantics and value mappings of each parameter. + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Does each sub-agent re-read its own prompt file each time it is called? | No → New candidates written by optimizer won't take effect; evaluation between candidates is equivalent to running the same baseline | +| Can end-to-end metrics reflect the joint quality of all fields? | No → Feedback signal seen by reflection LM is not real; recommend using `final_response_avg_score` to evaluate final response | +| How many LLM inferences does a single case go through? | Call volume multiplies by chain depth; need to correspondingly reduce `eval_case_parallelism` / `reflection_minibatch_size` to prevent rate limit | +| Do sub-agents need to be in the same process? | Not necessary—`call_agent` internals can be HTTP / gRPC / internal SDK / other orchestration frameworks; as long as it ultimately returns `str` | + +**→ Complete example**: [`examples/optimization/multi_agent_pipeline/`](../../../examples/optimization/multi_agent_pipeline/) +- `pipeline/orchestrator.py` — Multi-sub-agent chain implementation, sub-agents re-read prompts on each call +- `run_optimization.py` — Standard entry for multi-field `TargetPrompt` +- `optimizer.json` — Recommended configuration for multi-field scenarios + +### 4.4 My Prompts Are Not in Local Files, Stored in Remote Configuration Center / KV / Database {#44} + +**Your situation**: Business prompts are not in local files, but placed in a remote configuration center (QCS / Apollo / Nacos / self-developed KV / database / Git, etc.), and the business fetches and uses them from the center. The optimizer cannot directly access the file system—it can only interact with the remote through the business's own SDK. + +**Integration model**: `TargetPrompt` abstracts "where prompts are" into a pair of async functions `read` / `write`—the optimizer calls `read` to get the baseline snapshot, calls `write` to persist candidates; the remote backend form (KV / RPC / SQL / Git API ...) is **completely black box** to the optimizer. This is isomorphic to the structure coupled through local prompt files in §4.1 / §4.2, the difference is only replacing "read/write files" with "calling two async functions given by the business". + +```text ++-------------------+ async read / write +---------------------+ +| AgentOptimizer | <-------------------------------> | Remote config | +| (optimizer) | (your own SDK / HTTP / RPC) | (KV / DB / Git ...)| ++---------+---------+ +---------+-----------+ + ^ | + | best_prompts/ persisted locally | Business calls + | | pulls config + v v + +-------------------+ +---------------------------+ + | output_dir/ | | call_agent internals | + | best_prompts/ | | Pull latest prompt then | + +-------------------+ | call agent | + +---------------------------+ +``` + +**Integration in 3 steps**: + +**Step 1: Implement a pair of async functions to operate remote prompts** + +```python +async def read_prompt() -> str: + return await your_config_sdk.get(key="system_prompt") + +async def write_prompt(value: str) -> None: + await your_config_sdk.put(key="system_prompt", value=value) +``` + +Signature constraints: `read: async () -> str`, `write: async (str) -> None`. Retry / idempotency / authentication are guaranteed by the business's own SDK. + +**Step 2: Use `add_callback` instead of `add_path` to register `TargetPrompt`** + +```python +target = TargetPrompt().add_callback( + "system_prompt", + read=read_prompt, + write=write_prompt, +) +``` + +`add_callback` and `add_path` are peers on `TargetPrompt`—multi-field can also be mixed (some fields in local files, some fields in remote configuration center). + +**Step 3: Write `call_agent` as "pull now, use now", call `optimize` as usual** + +```python +async def call_agent(query: str) -> str: + prompt_text = await read_prompt() # Pull now, ensure candidate writes take effect immediately + agent = create_agent(prompt_text) + return await runner.run_async(query, ...) + +await AgentOptimizer.optimize( + config_path="optimizer.json", + call_agent=call_agent, + target_prompt=target, + train_dataset_path="train.evalset.json", + validation_dataset_path="val.evalset.json", + output_dir="runs//", + update_source=False, # Decision table see §3.4 +) +``` + +The value of `update_source` is determined by the business side's prompt write-back strategy (see §3.4 decision table for details), the framework has no additional restrictions on it. + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Does the business side re-pull configuration on each call? | No → After optimizer writes new candidate, business cannot perceive it, reflection loop fails | +| Are both `read` / `write` async functions? | No → Error reported immediately when registering with `add_callback` | +| Is `write` idempotent (accepts repeated writes of the same value)? | No → May fail when automatically rolling back to baseline at finish, leaving remote contaminated | +| Does the optimizer process have write permission for this key / namespace? | No → `write` throws permission error, current candidate evaluation fails | + +> **Safe mode involving production prompts** (adopt as needed, not forced by framework): If the business side already has sandbox / production namespace isolation, you can let the optimizer only read/write sandbox keys, cooperate with `update_source=False` to let the optimizer automatically roll back sandbox at finish, the best candidate is only persisted locally in `best_prompts/`, then synchronized to production through the business's own approval flow. `examples/optimization/remote_prompt_store/` demonstrates this workflow. + +**→ Complete example**: [`examples/optimization/remote_prompt_store/`](../../../examples/optimization/remote_prompt_store/) +- `store/prompt_client.py` — `read` / `write` async function definitions, core transformation point for integrating business configuration center SDK +- `run_optimization.py` — Standard entry for `add_callback` registration (demonstrates workflow using sandbox + `update_source=False` + manual approval) + +### 4.5 Single Evaluation Metric Is Insufficient, Need Multiple Metrics and Fuse into Total Score {#45} + +**Your situation**: Business launch has requirements for agent output in more than one dimension—answer must be correct (correctness hard constraint) + must not talk nonsense (hallucination rate) + style must comply with specifications (format / tone) + must not contain sensitive words (compliance)... Single metric cannot contain all, forcibly using a single composite metric means the feedback signal seen by the reflection LM is a mixed scalar, making it difficult to attribute directionally. + +**Integration model**: `optimizer.json`'s `evaluate.metrics` is a **list**—directly list multiple metrics, each scored independently, with independent threshold and independent configuration. Early stop determination declares which metrics must reach the threshold through `optimize.stop.required_metrics`; GEPA internally decides how to maintain the Pareto frontier among multiple metrics through `optimize.algorithm.frontier_type` to avoid "fixing A drags down B". The entire mechanism is purely configuration-driven—`call_agent` and `TargetPrompt` both do not need to change a single line of code for multi-metric. + +**Configuration in 3 steps**: + +**Step 1: List all metrics in `evaluate.metrics`** + +```jsonc +{ + "evaluate": { + "num_runs": 2, // Smooth LLM output variance (>1 lets each case run multiple times and take mean) + "metrics": [ + { + "metric_name": "llm_final_response", // Hard constraint: is answer substantively equivalent to reference + "threshold": 1.0, + "criterion": { "...": "..." } // Complete fields see §7 / example + }, + { + "metric_name": "llm_rubric_response", // Soft constraint: multiple rubrics (format / style / units ...) + "threshold": 0.75, + "criterion": { "...": "..." } + } + ] + } +} +``` + +Each metric is scored independently and written independently to `metric_breakdown` in `result.json`, convenient for reverse-attributing which metric a certain evaluation lost points on. + +**Step 2: Declare early stop gate in `optimize.stop.required_metrics`** + +| Value | Semantics | Applicable Scenario | +| --- | --- | --- | +| `"all"` | Early stop only when all metrics reach threshold | All metrics are must-pass items | +| `["m1", "m2"]` | Early stop only when all metrics in the list reach threshold (other metrics still participate in evaluation but do not affect early stop) | Some metrics are reference observation items, not used as gates | +| `null` or `[]` | Does not participate in early stop, only controlled by algorithm-level budget / no-improvement / score_threshold | Just want to run out the budget and see results | + +**Step 3: Adjust `frontier_type` to a value that correctly handles multiple metrics** + +| Value | Meaning | Applicable | +| --- | --- | --- | +| `instance` | Maintain one best candidate per case | Single metric or no obvious conflict between metrics | +| `objective` | Maintain one best candidate per metric | Multiple metrics but small case count | +| `hybrid` | Maintain both case + metric two-layer frontier | **Real conflict scenario with multiple metrics** (recommended default) | +| `cartesian` | One best candidate per (case, metric) combination | Extremely complex / debugging use, candidate pool easily explodes | + +`hybrid` lets GEPA not lose the best candidate on another metric when improving one metric—the **safe default for multi-metric business**. See [§7](#7-complete-api-reference) for the complete definition of each value. + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Do the `threshold` values of each metric conform to business requirements? | No → Early stop determination is inaccurate; business-critical metrics may not have reached standard when optimization ends | +| Are only "hard constraints" listed in `stop.required_metrics`? | No → Soft constraint fluctuations will repeatedly interrupt early stop determination, wasting budget | +| Does `eval_case_parallelism` consider the concurrency of metric count × judge count? | No → Single-round LLM call volume explodes (N cases × M metrics × K judges × `num_runs`), easily hitting LLM backend rate limit | +| Is `num_runs` reasonable (default 1)? | Single LLM judge output has variance; recommend `num_runs=2` to let each case run twice and take mean to eliminate jitter | + +**→ Complete example**: [`examples/optimization/multi_metric_with_judges/`](../../../examples/optimization/multi_metric_with_judges/) +- `optimizer.json` — Complete configuration example with `llm_final_response` (multi-judge `all_pass` voting) + `llm_rubric_response` (single judge multi-rubric) + `frontier_type=hybrid` + `stop.required_metrics` list style +- `run_optimization.py` — Standard entry consistent with single-metric scenarios (multi-metric does not affect entry code) + +### 4.6 Want to Integrate CI Closed Loop: PR Gate + Night Optimization Auto Write-Back {#46} + +**Your situation**: You hope prompt engineering also follows the CI/CD process—each PR automatically runs evaluation gate (score below threshold means CI red light, preventing degraded prompts from entering main branch), while simultaneously running reflection optimization in a low-peak window to write back better prompts, and the next PR automatically uses them. **Using either link alone is not enough**: pure gate will not automatically make prompts better, pure optimization has no quality gate. + +**Integration model**: `AgentEvaluator.evaluate` (pytest runs PR gate) and `AgentOptimizer.optimize` (night optimization) share **the same set of assets**—the same `call_agent`, the same evalset (physically split into train / val two files to prevent leakage, logically one set of corpus), the same pair of prompt files. `update_source=True` is the key switch for the closed loop: after optimization succeeds (`OptimizeResult.status=SUCCEEDED`), the optimal candidate directly overwrites the source prompt files, and the next PR-triggered pytest automatically reads the new content. + +```text + +-----------------------------------------------------+ + | Shared assets: call_agent + evalset + prompt files | + +------+----------------------------------------+-----+ + | | + Trigger: PR | | Trigger: Night window + v v + +---------------------------+ +---------------------------+ + | AgentEvaluator.evaluate | | AgentOptimizer.optimize | + | (pytest runs) | | update_source=True | + | | | | + | Score < threshold → Red | | Success → Overwrite | + | pytest exit != 0 → | | source prompts | + | Block PR | | Failure → Files unchanged| + +---------------------------+ +-------------+-------------+ + | + v + Next PR automatically + uses new prompts + (Forms "eval→optimize→eval" + evolution closed loop) +``` + +**Integration in 3 steps**: + +**Step 1: Extract `call_agent` into a module shared by evaluate / optimize** + +```python +# agent/agent.py (both pytest and optimizer import from here) +async def call_agent(query: str) -> str: + ... +``` + +**Why must share**: The agent used during evaluation and the agent used during optimization must be **equivalent**—otherwise "optimizer found a good prompt that evaluator cannot verify" or the reverse problem will occur. Sharing the same `call_agent` file is the most direct code-level guarantee. Any agent changes (model switch / temperature adjustment / output schema change) only need to be changed in one place. + +**Step 2: Write pytest entry for PR gate** + +```python +# tests/test_agent_quality.py +import pytest +from trpc_agent_sdk.evaluation import AgentEvaluator +from agent.agent import call_agent + +@pytest.mark.asyncio +async def test_agent_quality(): + await AgentEvaluator.evaluate( + call_agent=call_agent, + eval_set_path="data/val.evalset.json", + test_config_path="optimizer.json", # Reuse same metric configuration + ... + ) # Framework throws AssertionError when score is below threshold → pytest red +``` + +Run in CI pipeline: + +```bash +pytest tests/ --junitxml=runs/pytest_report.xml +``` + +The `--junitxml` output is a standard format test report, parsed natively by mainstream platforms like GitHub Actions / BlueKing Pipeline / Tencent CI. When failing, the `AssertionError` message contains the failure details JSON for each case; when the CI platform displays the stack trace, it can directly see which case failed, what the agent actually output, and where the difference from expected is. + +**Step 3: Night window runs optimization + `update_source=True`** + +```python +# run_optimization.py (triggered by night cron) +await AgentOptimizer.optimize( + config_path="optimizer.json", # Same metric configuration as pytest + call_agent=call_agent, # Same call_agent as pytest + target_prompt=target, + train_dataset_path="data/train.evalset.json", + validation_dataset_path="data/val.evalset.json", + output_dir="runs/optimize_/", + update_source=True, # Key switch for CI closed loop +) +``` + +Safety guarantee of `update_source=True`: Source prompt files are only written back when `OptimizeResult.status=SUCCEEDED`; source files remain unchanged in other states such as failure / budget exhaustion. Overwrite uses atomic write (tmp + `os.replace`), midway exceptions / SIGINT will not corrupt source prompt files (see [§8.3](#83-atomic-disk-persistence-guarantee) for details). + +It is recommended to add `git diff --quiet agent/prompts/` at the end of the night script to determine if there are changes; exit directly if no changes; if there are changes, then `git checkout -b ...` + automatically open a PR—letting new prompts go through the standard PR review process instead of directly entering main branch. + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Is `call_agent` **the same code** shared by pytest and optimizer? | No → Agent for evaluation and agent for optimization are not equivalent; optimization direction and gate direction drift | +| Do pytest and optimizer use **the same metric configuration**? | No → "Evaluation can pass but optimizer sees low score" or the reverse problem. Recommend reusing through `test_config_path` in pytest for the `optimizer.json.evaluate` section | +| Is evalset physically split into train / val two files? | No → SDK `_validate_inputs` forcibly validates `train != val`, otherwise reports error fail-fast | +| Does the night script have `git diff` + automatic PR opening steps at the end? | No → Optimized prompts directly enter main branch, bypassing review; recommend always going through PR process | +| Is there a grayscale strategy for prompt changes ready? | When multiple business lines share the same prompt repository, recommend switching to `update_source=False` + business's own grayscale deployment tool | + +**→ Complete example**: [`examples/optimization/ci_integration/`](../../../examples/optimization/ci_integration/) +- `agent/agent.py` — `call_agent` shared by pytest and optimizer +- `tests/test_agent_quality.py` — pytest gate entry (called at PR stage) +- `run_optimization.py` — Night optimization entry (`update_source=True`) +- `ci/run_pr_check.sh` / `ci/run_nightly_optimize.sh` — CI pipeline shell entries + +### 4.7 Optimization Task Has Hard Constraints: Must Complete Within a Time Window / Cumulative Calls Not Exceeding N / Stop After Consecutive No-Improvement {#47} + +**Your situation**: Your optimization task runs in a constrained environment—CI pipeline must end within N minutes, LLM backend quota is calculated monthly and single run cannot exhaust it, should actively give up after several consecutive rounds without improvement. **Single stop condition is not enough**: only setting timeout may stop before budget is used up, only setting budget may run until the end of time. You need a multi-stop strategy of "stop immediately when any SLO triggers". + +**Integration model**: The `optimize.algorithm` section of `optimizer.json` provides 6 algorithm-level stop conditions, with **OR semantics**—stop immediately when any one triggers. You reverse-calculate each threshold according to business SLO, and enable multiple switches simultaneously. When optimization ends, the `OptimizeResult.stop_reason` field tells you which SLO triggered first, convenient for subsequent parameter tuning. + +**Configuration in 3 steps**: + +**Step 1: Select several stop conditions that the business cares about from the 6 types** + +| Field | Trigger Condition | Typical Business Scenario | +| --- | --- | --- | +| `timeout_seconds` | Wall-clock exceeds N seconds | CI pipeline time window hard constraint (must end within N minutes) | +| `max_metric_calls` | Cumulative case evaluation count ≥ N | LLM backend quota hard upper limit | +| `max_candidate_proposals` | Reflection LM cumulative proposal count ≥ N | Limit reflection LM call budget | +| `max_iterations_without_improvement` | N consecutive rounds without best valset improvement | Actively give up when already converged or trapped in local optimum | +| `score_threshold` | Best valset pass_rate ≥ threshold | Already reached business goal, no need to continue | +| `max_tracked_candidates` | Pareto frontier candidate pool size ≥ N | Control memory and merge candidate space size | + +See [§7.3.3](#733-optimizealgorithm-section) for the complete definition of each field. **Configure at least 1**—otherwise the framework reports fail-fast at startup. + +**Step 2: Reverse-calculate each threshold according to business SLO** + +```jsonc +{ + "optimize": { + "algorithm": { + "timeout_seconds": 90.0, // CI must end within X minutes → set X*60 / 2 to leave buffer + "max_metric_calls": 30, // LLM quota → reverse-calculate by "calls × single-run duration" + "max_iterations_without_improvement": 3, // Give up after 3 consecutive rounds without improvement + "score_threshold": 1.0 // Stop when business goal is reached + } + } +} +``` + +**Two key reverse-calculations**: + +| Item | How to test | How to reverse-calculate | +| --- | --- | --- | +| Typical single-round duration | Run a baseline, look at `rounds[*].durationSeconds` in `runs//result.json` (take median) | `timeout_seconds` should be at least single-round duration × 2, otherwise the first round triggers stop and you cannot see optimization progress | +| Single-round metric_calls count | Same as above, look at `totalMetricCalls / totalRounds` in round | `max_metric_calls` should be able to run through at least `max_iterations_without_improvement` rounds, otherwise budget always triggers stop first | + +**Step 3: Clarify whether to participate in framework-level metric early stop** + +| Value | Semantics | +| --- | --- | +| `optimize.stop.required_metrics: "all"` or `["m1"]` | Metric reaching threshold also participates in OR trigger | +| `optimize.stop.required_metrics: []` | Only let the 6 algorithm-level stoppers decide | + +Business requirements: +- **Care about whether metrics reach standard** (typical prompt quality optimization) → use `"all"` or specific list +- **Only care about time / call budget** (known to converge, purely carding resources) → use `[]` + +**`stop_reason` value reference**: When optimization ends, the `OptimizeResult.stop_reason` value can tell you the trigger—`score_threshold_reached` / `budget_exhausted` / `timeout_reached` / `no_improvement` / `max_proposals_reached` / `max_tracked_candidates_reached` / `user_requested_stop` (user actively triggers through `optimize.stop` sentinel file). + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Are thresholds all reverse-calculated through baseline measurements, not intuited? | No → Highly likely some stopper always triggers first (e.g., timeout triggers in round 1), other configurations are decoration | +| Does `timeout_seconds` leave buffer (≤ 50% of real business window)? | No → Under the framework's "complete current round then stop" semantics, actual termination time may exceed the timeout set value, hitting business hard deadline | +| Do single-round LLM calls have their own timeout (e.g., CLI / HTTP calls)? | No → Single round hangs, entire timeout can only wait for current round to finish, may seriously exceed timeout (refer to CLI_TIMEOUT_SEC pattern in §4.2) | +| Have you run a baseline in the test environment once to verify `stop_reason` is consistent with expectations? | No → Only discover stopper behavior is inconsistent with expectations after going to CI, cannot quickly diagnose | + +**→ Complete example**: [`examples/optimization/slo_runtime_control/`](../../../examples/optimization/slo_runtime_control/) +- `optimizer.json` — Configuration example with all 6 stop conditions enabled (business real integration should reverse-calculate thresholds according to own SLO, do not directly copy example values) +- `run_optimization.py` — After running, `result.json.stop_reason` field identifies the trigger + +### 4.8 Can Already Run Through Basic Process, Want to Further Improve Results (GEPA Candidate Selection / Pareto Frontier / Cross-Field Fusion) {#48} + +**Your situation**: You have already run through the basic optimization process according to quickstart, and can stably see score improvement from baseline → best. Now you want to understand several advanced switches of GEPA—`candidate_selection_strategy` / `frontier_type` / `use_merge` / `skip_perfect_score`—whether they are **actually useful on your task, whether they can squeeze out a few more points**. But running optimization once often cannot see the difference, because GEPA can converge to similar `best_pass_rate` on most tasks—**the difference is hidden in the arrival path** (round count / acceptance rate / whether merge triggered / reflection LM call count), not in the final score. + +**Integration model**: Use **A/B controlled experiment**—same business, same evalset, same `seed`, run two different `optimizer.json`: one is the current online configuration or default configuration (baseline), one is the advanced combination to be verified. After running, compare the two `result.json`, focusing on **multi-dimensional metrics** rather than single `best_pass_rate`. + +**Experiment in 3 steps**: + +**Step 1: Use current configuration as baseline, fix other variables** + +```jsonc +// optimizer_baseline.json +{ + "optimize": { + "algorithm": { + "seed": 42, // Fix seed to exclude randomness + "max_metric_calls": 30, // Keep consistent with advanced to fairly compare + "candidate_selection_strategy": "pareto", + "frontier_type": "instance", + "skip_perfect_score": false, + "use_merge": false + } + } +} +``` + +**Step 2: Write advanced configuration, only change the switches to be verified** + +```jsonc +// optimizer_advanced.json (only differs from baseline by a few switches) +{ + "optimize": { + "algorithm": { + "seed": 42, + "max_metric_calls": 30, + "candidate_selection_strategy": "pareto", + "frontier_type": "objective", // Change: from instance to objective + "skip_perfect_score": true, // Change: skip perfect score cases to save reflection calls + "use_merge": true // Change: enable cross-field fusion (only actually triggers in multi-field) + } + } +} +``` + +**Step 3: Run twice + parse `result.json` to output multi-dimensional comparison** + +```bash +python run_baseline.py # Produce runs/baseline_/result.json +python run_advanced.py # Produce runs/advanced_/result.json +python compare.py # Parse two result.json, output comparison table +``` + +Dimensions `compare.py` should focus on: + +| Dimension | Field (indexed by camelCase in `result.json`) | Interpretation | +| --- | --- | --- | +| Final quality | `bestPassRate` / `baselinePassRate` | End-to-end score improvement; two strategies converge closely on most tasks | +| Exploration depth | `totalRounds` / `roundsAccepted` | Acceptance rate (`roundsAccepted / totalRounds`) reflects frontier acceptance threshold | +| Merge behavior | `mergeRoundsTotal` / `rounds[*].kind` | Verify `use_merge=true` actually triggers merge | +| Reflection budget | `metricCallsTotal` / `proposalsTotal` | `skip_perfect_score=true` saves more obviously on large training set + high baseline start | +| `stop_reason` | `stopReason` | Which stopper triggered; cannot directly compare when advanced/baseline have different stop_reason | + +> **Pitfall reminder**: Fields in `result.json` are camelCase (`bestPassRate` not `best_pass_rate`). SDK uses snake_case internally, automatically converted to camelCase during serialization through pydantic alias. Index by camelCase when reading `result.json`. + +**Expected performance of several advanced switches** (may not all hold on business tasks—use your own actual measurements as basis): + +| Switch | Expected Benefit | Applicable Prerequisites | +| --- | --- | --- | +| `frontier_type="objective"` (vs `"instance"`) | Higher acceptance rate / more aggressive exploration | Multi-metric scenario; may overfit train minibatch on small training set (< 10 cases) causing valset oscillation | +| `frontier_type="hybrid"` | Multiple metrics do not overwrite each other | Real conflict scenario with multiple metrics (see §4.5) | +| `skip_perfect_score=true` | Save reflection LM calls | Large-scale training set + high baseline start; few perfect score cases on small dataset, limited savings | +| `use_merge=true` | Cross-field fusion candidates | **Only actually triggers when multi-field (`add_path` ≥ 2)**; always 0 merge rounds in single-field configuration (`mergeRoundsTotal=0` is expected, see §4.3) | + +**Pre-integration checklist**: + +| Check Item | Consequence of Failure | +| --- | --- | +| Do the two configurations only differ in **the few switches to be verified**, all others identical? | No → Comparison result contains confounding variables, conclusion is not credible | +| Is `seed` consistent between the two sets? | No → Difference may come from randomness rather than configuration strategy | +| Is `max_metric_calls` consistent between the two sets? | No → One set naturally has higher score with more budget, cannot attribute to strategy | +| Are you simultaneously focusing on **multi-dimensional comparison** rather than single `bestPassRate`? | No → Final scores of two strategies are close on most tasks, cannot see difference; difference is hidden in arrival path | +| Do switches like `use_merge` / `skip_perfect_score` make sense in your task structure? | Enabling `use_merge` on single-field task never triggers (harmless but no benefit); enabling `skip_perfect_score` on high-baseline task saves considerably | + +> Advanced configuration is **not the more complex the better**. On many tasks, baseline configuration can already achieve reasonable convergence; advanced only shows value in specific task structures (multi-objective, multi-field, large-scale training set, etc.). **Use data to decide, not intuition**. + +**→ Complete example**: [`examples/optimization/advanced_strategies/`](../../../examples/optimization/advanced_strategies/) +- `optimizer_baseline.json` / `optimizer_advanced.json` — Two configurations for A/B control (only differ by 3 switches) +- `run_baseline.py` / `run_advanced.py` — Two independent entries (keeping other variables consistent) +- `compare.py` — Standard template for parsing two `result.json` and outputting multi-dimensional comparison table + +## 5 How GEPA Works + +After running an optimization and watching the score increase from 0.4 to 0.85, you don't know **what exactly the framework did along the way**—what data did it read? What did the reflection LM see? On what basis did it decide to retain or discard a candidate? When SLO triggers, does it stop immediately or wait for the current round to finish? + +> **GEPA** = Genetic-Evolutionary Pareto, is a reflection-based evolutionary search algorithm ([gepa-ai/gepa](https://github.com/gepa-ai/gepa), MIT License). This framework wraps `gepa.optimize()` into `GepaReflectiveOptimizer` through `OPTIMIZER_REGISTRY`, and adds a layer of SDK adaptation (evaluation bridging, reflection feedback construction, stop determination, atomic disk persistence, etc.). + +### 5.1 What Exactly Runs in One Optimization Round + +**First remember three roles**—all subsequent diagrams and tables revolve around these three: + +| Role | Who Is It | What It Does | +| --- | --- | --- | +| **agent** | Your business agent (accessed through `call_agent`) | Receives one query, outputs one response | +| **judge / metric** | Configured evaluators in `evaluate.metrics` | Score agent responses (0~1) | +| **Reflection LM** | LLM configured in `algorithm.reflection_lm` | Views failure case feedback → generates new prompt candidates | + +**Round 0**: Run valset with baseline prompt → get baseline score (your "starting line") + +**Each subsequent round (reflective round)** follows these 5 steps: + +```text + ┌────────────────────────────┐ + │ Candidate prompt selected │ + │ in previous round │ + └──────────────┬─────────────┘ + ▼ + (1) Sample minibatch → Randomly sample N cases from trainset + (N = reflection_minibatch_size) + │ + ▼ + (2) Run one evaluation → Write candidate to prompt file + → Call call_agent to run these N cases + → Metric scores, get failure cases + │ + ▼ + (3) Reflection LM → Feed failure case feedback to + generates candidate reflection LM + → It outputs new prompt text + │ + ▼ + (4) Re-evaluate + enter → Re-run new candidate on minibatch + Pareto frontier → Better than historical → enter + frontier, otherwise discard + │ + ▼ + (5) Check stop conditions → Any of 6 stoppers triggered → stop + → Otherwise enter next round +``` + +**Several key explanations**: + +- **"Evaluation" in step (2)** actually runs `len(minibatch) × num_runs × len(metrics)` LLM evaluations (see §6.1 for details) +- **"What reflection LM sees" in step (3)** determines rewrite quality—this is the content of next section §5.2 +- **"Pareto frontier" in step (4)** simply put is "retain the set of candidates that are not surpassed in all aspects"; specific granularity is controlled by `frontier_type` (see §5.3 for details) +- **"Stop when any triggers" in step (5)** has a detail: after triggering, **wait for current round to finish before actually stopping**, not immediately kill (see §5.4 for details) +- **Valset evaluation** is interleaved in the middle rounds (determined internally by gepa), used to calculate the "real score of current best candidate on valset", also the basis for stopper judgments such as `score_threshold` / `required_metrics` + +**Special case: merge round** + +When `use_merge=true`, a **merge round** is inserted every several reflective rounds: select two candidates from the Pareto frontier and fuse them into one new candidate ("take A's wording on field X + B's wording on field Y"). **Only meaningful in multi-field scenarios**—never triggers in single-field, `mergeRoundsTotal=0` is expected. See §4.3 for details. + +### 5.2 What Reflection LM Actually Sees + +The quality of the reflection LM's prompt rewriting **completely depends on how rich the failure feedback it can see**. If you only tell it "case_3 failed, score 0.3", it can only guess blindly; if you tell it "case_3 turn 2 agent should output `{"city":"Shanghai"}` but actually output `Shanghai`, rule requires case-sensitive exact match", it can targetedly modify the prompt. + +`_AgentGEPAAdapter.make_reflective_dataset` renders a markdown record for each **failed case**, fed to the reflection LM. Each record field: + +| Field | One-Line Explanation | When It Appears | +| --- | --- | --- | +| `case_id` | Stable ID of the case (for reflection LM cross-reference) | Always | +| `score` | Aggregate score of this case (0~1, 1.0 = all metrics passed) | Always | +| `Case Body` | Markdown of failure scene: one segment per turn, containing user input, expected response, agent actual response, tool call trace, each metric's judgment (PASS/FAIL + score + failure reason) | Always | +| `Other Active Components` | What do other prompt fields NOT being rewritten in this round look like | When multi-field optimization—lets reflection LM see B/C status when modifying A, avoiding breaking upstream/downstream compatibility | +| `history_top_k` | Best agent responses for this case in history (sorted by score) | When `reflection_history_top_k > 0` | + +**Specific structure of `Case Body`**: + +```text +### Turn 1 +**User**: +**Expected**: +**Agent Response**: +**Tool Trace**: ← Only when tool calls exist + - tool_name(args) → response +**Verdict** (Turn 1): + [FAIL] metric_name: score=0.0000, threshold=1.0000 + reason: agent output not byte-equal to expected (case-sensitive) + · rubric[no_emoji]: PASS score=1.00 ← Only for LLM rubric metric + +### Turn 2 +... + +### Overall (case-level aggregate) ← When multi-turn or multi-run +... +``` + +**Failure reason synthesis for deterministic metrics**: When metric is an evaluator without LLM judge like `final_response_avg_score`, only outputting score+status, the framework will **automatically synthesize a failure explanation** (e.g.: `agent output not byte-equal to expected (case-sensitive)` / `expected substring not contained in agent output (case-insensitive)` / `JSON structural comparison failed`), letting the reflection LM directly see **why it didn't match**, without having to diff text to guess. + +> Want to see the full reflection prompt that the reflection LM actually receives? Set `verbose=2` when running optimization, gepa internal logs will include each round's reflection prompt text—read it once and you'll have a good understanding. + +### 5.3 Actual Behavior of 5 Core Operators + +The 5 switches most frequently asked about in the `optimize.algorithm` section of `optimizer.json`, what they actually do in the source code: + +| Operator | One-Line Function | Typical Motivation to Adjust It | Detailed Reference | +| --- | --- | --- | --- | +| `reflection_minibatch_size` | How many cases the reflection LM sees each round | Smaller saves tokens, larger gives reflection LM more complete view | [§7.3.3](#733-optimizealgorithm-section) | +| `module_selector` | Which field to modify this round in multi-field (`round_robin` rotation / `all` select all / `random` random) | Want clear attribution of each field's contribution → `round_robin` | [§4.3](#43) | +| `frontier_type` | Pareto frontier granularity (`instance` one best per case / `objective` one per metric / `hybrid` two-layer / `cartesian` Cartesian product) | When multiple metrics truly conflict → `hybrid` | [§4.5](#45) | +| `candidate_selection_strategy` | How to select parent for next round's reflection (`pareto` default select from frontier / `current_best` use current best / etc.) | Want to accelerate convergence or increase exploration | [§7.3.3](#733-optimizealgorithm-section) | +| `use_merge` + `max_merge_invocations` | Whether to enable cross-field fusion + upper limit on trigger count | **Only actually triggers in multi-field**—`mergeRoundsTotal=0` is expected in single-field | [§4.3](#43) / [§4.8](#48) | + +### 5.4 Stop Timing: Complete Current Round Before Stopping + +6 algorithm-level stop conditions (`max_metric_calls` / `timeout_seconds` / `no_improvement` / `score_threshold` / `max_candidate_proposals` / `max_tracked_candidates`) are **synchronously checked at the end of each round**—stop when any condition is satisfied. + +**3 easily stepped-on details**: + +| Detail | Meaning | How to Avoid | +| --- | --- | --- | +| **Does not immediately kill current round** | When stop is triggered, it will not interrupt the currently running round; must wait for current round to finish before actually stopping | In SLO hard deadline scenarios, set `timeout_seconds` to about 50% of the real business window, leave buffer | +| **Actual termination time often exceeds `timeout_seconds`** | Direct consequence of the previous point—especially obvious when stuck in a long round | Add your own timeout to LLM calls inside `call_agent` (refer to 90s timeout in §4.2 CLI) | +| **Priority when multiple stoppers trigger simultaneously** | `framework_stopper` (`required_metrics` policy) first; then take the first one in algorithm-level stopper insertion order | `OptimizeResult.stop_reason` field records the trigger, see which one triggered directly after running | + +**`stop_reason` value reference** (`OptimizeResult.stop_reason`): + +``` +required_metrics_passing ← framework-level (highest priority) +score_threshold ← Reached target score +budget_exhausted ← max_metric_calls +timeout ← timeout_seconds +no_improvement ← max_iterations_without_improvement +max_candidate_proposals +max_tracked_candidates +user_requested_stop ← User touched optimize.stop file +completed ← No stopper triggered, gepa naturally finished +``` + +### 5.5 A Special Case: FAILED + +Normally `OptimizeResult.status = "SUCCEEDED"`—gepa finished the loop (natural end / stopper trigger both count). But there is one special status worth user attention: + +- **`status = "FAILED"`**: gepa threw an exception during running (most common: training/validation set loading failure, `gepa.optimize()` internal exception, reflection LM call failure) +- **At this time `best_prompts` is forcibly set to `baseline_prompts`**—ensuring the artifacts you get **will never be worse than baseline** +- **`update_source=True` will not write back** source prompt files when FAILED (see §3.4 decision table for details) + +Another easily confused point is "finished running but no improvement": in this case `status` is still `"SUCCEEDED"`, but `finish_reason="no_improvement"`, and `best_prompts == baseline_prompts`—`summary.txt` will show `baseline → baseline` (no degradation nor improvement). This is expected, not a bug. + + +## 6 Cost and Concurrency + +How many LLM calls does one optimization run require? Which knobs affect call volume, which affect concurrency, which affect both? + +### 6.1 Where LLM Calls in One Optimization Come From + +LLM calls are divided into two parts—**evaluation side eats the vast majority**, reflection side is just a fraction: + +**Evaluation side (agent + judge)**: Run each of these once, each calls LLM once— + +```text +Run one baseline evaluation: Run valset fully once ← Starting point, 1 time +Each reflective round: Sample N cases and run once + re-run candidate ← Main cost +Specific reflective round: Re-evaluate current best candidate on valset ← Determined by gepa +``` + +Actual LLM call count triggered by each "run once" = **number of cases × agent call count per case × `num_runs` × judge call count per metric**. Among them: + +| Multiplier | Source | Typical Value | +| --- | --- | --- | +| Agent call count per case | Evalset data; accumulate by turn count in multi-turn conversation | Single turn = 1, multi-turn = N | +| `evaluate.num_runs` | Run each case several times and take mean to eliminate LLM output variance | 1 (default, saves) / 2~3 (recommended, stable) | +| Judge call count per metric | Depends on metric type: `final_response_avg_score` type deterministic matching = 0 times; `llm_judge` / `llm_rubric_response` ≥ 1 time (however many are in `judge_models` array) | 0~3 | + +**Reflection side (reflection LM)**: + +```text +Each reflective round: 1 time (generate new candidate prompt) +Each merge round: 1 time (only when use_merge=true and multi-field) +``` + +Reflection side call count is much less than evaluation side—usually 5~20 times for a complete optimization. + +### 6.2 What to Read from result.json After Running + +Fields actually recorded in `OptimizeResult` (camelCase indexed in artifact `result.json`): + +| Field | Meaning | +| --- | --- | +| `totalMetricCalls` | Cumulative case-level evaluation count by gepa | +| `totalReflectionLmCalls` | Cumulative reflection LM call count (including retries) | +| `totalTokenUsage` | Cumulative tokens for reflection LM: `{prompt, completion, total}` | +| `durationSeconds` | Total wall-clock duration | + +When needing to estimate actual USD cost on the business side, use `totalTokenUsage` × LLM backend unit price to reverse-calculate reflection side; agent / judge side is pulled from LLM backend usage records (API console / billing reports). + +### 6.3 Multiplier Effect of 4 Commonly Used Knobs + +Sorted by "magnitude of impact on total call volume" from large to small—when encountering optimization running out of budget, adjust the ones above first: + +| Knob | Multiplies By How Much | Cost of Turning Down | Details | +| --- | --- | --- | --- | +| `algorithm.max_metric_calls` | **Hard upper limit on total call volume**—gepa stops when cumulative reaches it | Too small → Stopped by it in the 1st round; cannot see any score improvement | [§4.7](#47) | +| `evaluate.num_runs` | **Multiply by N**—run each case N times and take mean | LLM output variance directly enters score when 1 (same prompt gets different scores on two runs); recommend ≥ 2 | [§4.5](#45) | +| `optimize.eval_case_parallelism` | **Does not affect total volume**, only affects **wall-clock time** and **instantaneous QPS** | Higher saves time but easily hits LLM backend rate limit | [§4.5](#45) | +| `algorithm.reflection_minibatch_size` | **Multiply by a few**—how many cases the reflection LM sees each round; evaluation side also calculates by this number | Too large → Reflection prompt explodes LLM context window | [§4.3](#43) | + +### 6.4 Want to Reasonably Set Thresholds? Run a Baseline First + +Before setting thresholds such as `timeout_seconds` / `max_metric_calls`, **first run a baseline with default configuration**—read two numbers from the artifacts: + +| Value to Measure | How to Test | How to Use | +| --- | --- | --- | +| **Typical single-round duration** | `rounds[*].durationSeconds` in `runs//result.json` (take median) | `timeout_seconds` should be at least single-round duration × 2, otherwise stop is triggered in round 1 and you cannot see optimization progress | +| **Single-round metric_calls** | Same as above, `totalMetricCalls / totalRounds` | `max_metric_calls` should be able to run through at least `max_iterations_without_improvement` rounds, otherwise budget always triggers stop first | + +**Example**: Baseline run shows 30 seconds per round, 4 metric_calls per round, CI window 5 minutes—then `timeout_seconds=120` (leave buffer), `max_metric_calls=24` (enough to run 6 rounds for `max_iterations_without_improvement=3` to trigger stop). + +### 6.5 Single-Round Instantaneous LLM QPS Control + +Number of LLM requests concurrently sent in a single round: + +```text +Single-round instantaneous LLM QPS ≈ eval_case_parallelism + × num_runs + × (agent calls per case + all judge calls) +``` + +**Typical scenario estimation**: 3 judges + `num_runs=2` + `eval_case_parallelism=4` + 1 agent call per case + 3 judge calls → about 32 LLM requests per round instantaneous. When LLM backend rate limit is 30 QPS, this configuration will inevitably trigger rate limiting. + +**Two parameters to control instantaneous QPS** (sorted by effect): + +| Parameter | Impact | Applicable | +| --- | --- | --- | +| `eval_case_parallelism` | Directly reduces concurrent case count | First choice for most situations; set to `1` for serial execution in scenarios with intensive single-case calls such as black-box CLI, multi-judge (see [§4.2](#42), [§4.5](#45)) | +| `num_runs` | Reduces repeated evaluation per case | Sacrifices some variance stability; recommend only lowering after confirming LLM output variance is small | + +### 6.6 Reflection LM Selection and Configuration + +The output quality of the reflection LM directly determines prompt rewriting quality. Configuration location (`optimizer.json`): + +```jsonc +{ + "optimize": { + "algorithm": { + "reflection_lm": { + "model_name": "${TRPC_AGENT_MODEL_NAME}", + "base_url": "${TRPC_AGENT_BASE_URL}", + "api_key": "${TRPC_AGENT_API_KEY}", + "generation_config": { + "max_tokens": 4096, // Reflection prompt is long, leave enough output space + "temperature": 0.6 // Between 0.6~0.8, let LM be creative + } + } + } + } +} +``` + +**Two suggestions**: + +- **Can be configured independently from agent / judge**—the `reflection_lm` section is independent, business can choose different model (avoid "self-evaluation" bias, or purely because reflection tasks require higher model reasoning power) +- **Token usage is truly recorded**—the `totalTokenUsage` field will accumulate actual prompt + completion + total token count for reflection LM; reverse-calculate USD by LLM backend unit price + + +## 7 Complete API Reference + +Reference manual section, organized by "what parameter are you looking for". **Each table has a "Required" column**, three-gear meaning: + +- **Required**: Not passed/not configured → fail-fast error at startup +- **Optional**: Can be omitted; uses default value when not configured +- **Conditionally Required**: Can be omitted when looking at the entry alone, but **must be configured when satisfying certain conditions**—conditions written in the "Condition" column at the end of each entry + +All fields are based on actual source code (source file path annotated in each table header). + +### 7.1 `AgentOptimizer.optimize` Parameter Table + +Source code: `trpc_agent_sdk/evaluation/_agent_optimizer.py:AgentOptimizer.optimize`. **11 keyword-only parameters**—must be passed in `key=value` form, positional parameters not accepted. + +| Parameter | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `config_path` | **Required** | `str` | — | optimizer.json configuration file path | +| `call_agent` | **Required** | `async (str) -> str` | — | Business agent adapter function; signature fixed as "accept query return str" | +| `target_prompt` | **Required** | `TargetPrompt` | — | Register which prompt fields are optimization targets (at least 1, otherwise error) | +| `train_dataset_path` | **Required** | `str` | — | Training evalset file path | +| `validation_dataset_path` | **Required** | `str` | — | Validation evalset file path; **must be different from `train_dataset_path`** (prevent data leakage, framework will normalize paths before comparing) | +| `output_dir` | **Required** | `str` | — | Artifact directory; created automatically if it doesn't exist | +| `callbacks` | Optional | `Optional[Callbacks]` | `None` | Evaluator lifecycle callbacks (rarely used) | +| `update_source` | Optional | `bool` | `False` | Whether to write back to source prompt files after successful optimization (decision table see [§3.4](#34-agentoptimizer)) | +| `verbose` | Optional | `int` | `1` | Terminal output verbosity: `0` silent / `1` default Rich panel / `2` plus gepa internal log forwarding | +| `extra_stop_callbacks` | Optional | `Optional[Sequence]` | `None` | Stoppers appended at runtime (SLO monitoring / kill switch, etc.); ordinary callable displays as `stop_reason="completed"`, use `_LabeledStopper` wrapper or expose `.label` attribute when needing stable labels | +| `extra_gepa_callbacks` | Optional | `Optional[Sequence]` | `None` | Gepa event callbacks appended at runtime (e.g., forwarding to dashboard); need to implement `gepa.core.callback.GEPACallback` protocol | + +**Return value**: `OptimizeResult` (see [§7.4](#74-optimizeresult--roundrecord-field-table) for details). + +**Fail-fast checks at startup** (`_validate_inputs`): + +| Situation When Check Fails | Throws | +| --- | --- | +| `output_dir` is empty string | `ValueError` | +| `target_prompt` did not register any fields | `ValueError` | +| `call_agent` is not async function (including `__wrapped__` check, supports `functools.partial` wrapped async) | `TypeError` | +| `train_dataset_path` and `validation_dataset_path` resolve to the same file (compared after normalizing with `os.path.normpath(os.path.abspath(...))`) | `ValueError` (prevent data leakage) | +| `evaluate.metrics` contains `tool_trajectory_avg_score` or `llm_rubric_knowledge_recall`—these two require session traces / tool intermediate_data, which cannot be obtained in `call_agent` black-box mode | `ValueError` | +| `algorithm.name` in config is not registered in `OPTIMIZER_REGISTRY` | `ValueError` (message lists all registered algorithm names) | +| `use_merge=true` and `TargetPrompt` field count < 2 | `UserWarning` (not fatal, but `mergeRoundsTotal` will always be 0) | + +### 7.2 `TargetPrompt` API Table + +Source code: `trpc_agent_sdk/evaluation/_target_prompt.py`. A container for registering multi-field prompts, supports both file source and callback source forms. + +| Method | Signature | Behavior | +| --- | --- | --- | +| `add_path(name, path)` | `(str, str) -> Self` | Register file source field; `name` must be unique; returns self for chained calls | +| `add_callback(name, *, read, write)` | `(str, *, AsyncRead, AsyncWrite) -> Self` | Register callback source field; `read: async () -> str`, `write: async (str) -> None` must both be async; `name` must be unique | +| `names()` | `() -> list[str]` | Return field names (in registration order) | +| `describe_source(name)` | `(str) -> str` | File source returns path; callback source returns literal `""`; unknown name throws `KeyError` | +| `read(name)` | `async (str) -> str` | Read single field | +| `read_all()` | `async () -> dict[str, str]` | Read all fields (in registration order) | +| `write_all(prompts)` | `async (dict[str, str]) -> None` | **Atomically write all fields** (see contract below for details) | + +**Atomicity contract of `write_all`** (from source code comments): + +1. **File source atomic write**: First write to `.tmp`, then `os.replace` rename (POSIX guarantees rename atomicity) +2. **Failure rollback**: When any file write fails, already successfully written files roll back to pre-call content, clean up residual `.tmp`, original exception normally re-raised +3. **Rollback itself fails**: Original exception is preserved through `__context__`, and `_RollbackError` is raised listing each field's rollback failure details—rollback is best-effort, one field's failure does not skip subsequent ones +4. **Callback source does not rollback**: After file source writes successfully, then run callback sources in order; when callback source fails, file source rolls back to baseline, but **callback source itself does not rollback** (idempotency is caller's responsibility) + +**Key validation of `write_all`**: The key set of incoming `prompts` must **exactly equal** the registered field name set, otherwise throws `ValueError`. + +### 7.3 `optimizer.json` Configuration Items Table + +Source code: `trpc_agent_sdk/evaluation/_optimize_config.py`. pydantic schema, **supports both camelCase and snake_case keys**. Top-level structure: + +```jsonc +{ + "evaluate": { ... }, // Evaluation section (same schema as AgentEvaluator) + "optimize": { // Optimizer section + "eval_case_parallelism": 4, + "stop": { ... }, // Framework-level stop + "algorithm": { ... } // Algorithm block (including reflection_lm) + } +} +``` + +#### 7.3.1 `evaluate` Section + +Source code: `_eval_config.py:EvalConfig`. + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `metrics` | **Conditionally Required** (see below) | `Optional[list[dict]]` | `None` | Metric array, each containing `metric_name` / `threshold` / `criterion`. **When `metrics` is configured, `criteria` is ignored** | +| `criteria` | **Conditionally Required** (see below) | `dict[str, Any]` | `{}` | Old-style shorthand: `metric_name → threshold` or `{threshold, criterion}` | +| `num_runs` | Optional | `int` | `1` | How many times to run each case and take mean (eliminate LLM output variance); `≥ 2` recommended | +| `user_simulator_config` | Optional | `Optional[Any]` | `None` | User simulator configuration (multi-turn scenarios; rarely used) | + +**Condition**: At **least 1** of `metrics` and `criteria` must be configured—when both are empty, `evaluate.get_eval_metrics()` returns empty list, and startup will report error due to no metrics. New integrations recommend using `metrics` (more structured), `criteria` is mainly kept for compatibility with old configurations. + +#### 7.3.2 `optimize` Section + +Source code: `_optimize_config.py:OptimizeConfig`. + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `eval_case_parallelism` | Optional | `int` | `4` | Case concurrency within same round (does not affect total call volume, affects instantaneous QPS) | +| `stop` | Optional | `FrameworkStopConfig` | `{required_metrics: "all"}` | Framework-level stop section (see [§7.3.5](#735-optimizestop-section) for details) | +| `algorithm` | **Required** | `GepaReflectiveAlgo` | — | Algorithm block (see [§7.3.3](#733-optimizealgorithm-section) for details) | + +#### 7.3.3 `optimize.algorithm` Section + +Source code: `_optimize_config.py:GepaReflectiveAlgo`. All adjustable parameters for the `gepa_reflective` algorithm. + +> **Hard constraint**: Among the **last 6 stopper fields** in the table, **at least 1 must be configured**—if all are left empty (default `None`), it will be rejected by `_require_at_least_one_stop_condition`, throwing `ValueError` fail-fast. This is why they are marked as "Conditionally Required". + +**Basic fields**: + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | **Required** | `Literal["gepa_reflective"]` | — | Algorithm selector; currently the only optional value | +| `reflection_lm` | **Required** | `OptimizeModelOptions` | — | Reflection LM configuration (see [§7.3.4](#734-optimizealgorithmreflection_lm-section) for details) | +| `seed` | Optional | `int` | `42` | Random seed; two sets of configurations should be consistent when A/B testing | + +**Search behavior fields**: + +| Field | Required | Type | Default | Values and Description | +| --- | --- | --- | --- | --- | +| `candidate_selection_strategy` | Optional | Literal | `"pareto"` | `pareto` select from frontier (default recommended) / `current_best` use current best / `epsilon_greedy` exploration-exploitation / `top_k_pareto` random from top K of frontier | +| `module_selector` | Optional | `str` | `"round_robin"` | Which field to modify this round in multi-field: `round_robin` rotate in registration order / `all` select all / `random` random | +| `frontier_type` | Optional | Literal | `"instance"` | Pareto frontier granularity: `instance` one best per case / `objective` one per metric / `hybrid` two-layer / `cartesian` Cartesian product | +| `reflection_minibatch_size` | Optional | `Optional[int]` | `None` | Minibatch size for each round's reflection; `None` lets gepa decide | +| `reflection_history_top_k` | Optional | `int` (0~5) | `2` | How many historical best responses to give reflection LM for each case; 0 disables, upper limit 5 | +| `perfect_score` | Optional | `float` | `1.0` | "Perfect score" threshold (used with `skip_perfect_score`) | +| `skip_perfect_score` | Optional | `bool` | `True` | Skip cases that already have perfect score during reflection | + +**Multi-field fusion (merge) fields**: + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `use_merge` | Optional | `bool` | `False` | Enable merge round; **only actually triggers in multi-field (≥2)**, never triggers in single-field and won't report error (only `UserWarning`) | +| `max_merge_invocations` | Optional | `int` | `5` | Upper limit on merge trigger count | +| `merge_val_overlap_floor` | Optional | `int` | `5` | Minimum val set case overlap count to trigger merge | + +**Performance fields**: + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `cache_evaluation` | Optional | `bool` | `False` | Cache (candidate, case) scores; skip directly on repeated evaluation | +| `track_best_outputs` | Optional | `bool` | `False` | Track best output for each case | + +**6 stop condition items**—**configure at least 1** (OR semantics trigger): + +| Field | Required | Type | Default | Trigger Condition | +| --- | --- | --- | --- | --- | +| `max_metric_calls` | Conditionally Required | `Optional[int]` | `None` | Cumulative case-level evaluation count ≥ N → stop | +| `max_iterations_without_improvement` | Conditionally Required | `Optional[int]` | `None` | N consecutive rounds without best valset improvement → stop | +| `timeout_seconds` | Conditionally Required | `Optional[float]` | `None` | Wall-clock exceeds N seconds → stop | +| `score_threshold` | Conditionally Required | `Optional[float]` | `None` | Best valset score ≥ N → stop | +| `max_candidate_proposals` | Conditionally Required | `Optional[int]` | `None` | Candidate proposal count ≥ N → stop | +| `max_tracked_candidates` | Conditionally Required | `Optional[int]` | `None` | Pareto candidate pool size ≥ N → stop | + +**Condition**: At least 1 of the 6 items must be non-`None`, otherwise fail-fast at startup. See [§4.7 SLO Hard Constraints](#47) for details. + +#### 7.3.4 `optimize.algorithm.reflection_lm` Section + +Source code: `_optimize_model_options.py:OptimizeModelOptions`. Reflection LM connection configuration. + +> **Only need to configure 4 in daily use**: `model_name` / `base_url` / `api_key` / `generation_config` (leave others as default). The 6 items marked "advanced" in the table below generally do not need to be touched. + +| Field | Required | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `model_name` | **Required** | `str` | `""` | Model name (e.g., `"gpt-4o-mini"`); empty string equals not configured, will report error at startup | +| `base_url` | Optional | `Optional[str]` | `None` | Custom endpoint URL | +| `api_key` | Optional | `str` | `""` | API key (most providers must provide, otherwise will report error at call stage) | +| `generation_config` | Optional | `Optional[dict]` | `None` | Generation parameters; typical: `{"max_tokens": 4096, "temperature": 0.6}` | +| `provider_name` | Advanced | `str` | `""` | Provider name; empty / `"openai"` goes to `OpenAIModel`, other values go to `ModelRegistry.create_model("{provider}/{model}")` | +| `variant` | Advanced | `str` | `""` | OpenAI-compatible variant (only when provider is openai) | +| `extra_fields` | Advanced | `Optional[dict]` | `None` | Extra fields transparently passed to underlying model | +| `num_samples` | Advanced | `Optional[int]` | `None` | Number of samples | +| `weight` | Advanced | `float` | `1.0` | Weight (multi-judge scenarios) | +| `think` | Advanced | `Optional[bool]` | `None` | Whether to enable thinking mode | + +**Field values support environment variable expansion**—`"${TRPC_AGENT_API_KEY}"` will be automatically replaced. + +#### 7.3.5 `optimize.stop` Section + +Source code: `_optimize_config.py:FrameworkStopConfig`. + +| Field | Required | Type | Default | Values | +| --- | --- | --- | --- | --- | +| `required_metrics` | Optional | `Optional[Union[Literal["all"], list[str]]]` | `"all"` | `"all"`: all metrics must reach threshold; `["m1", "m2"]`: listed metrics must reach threshold (other metrics still participate in evaluation but do not affect early stop); `null` or `[]`: disable framework-level early stop (rely only on algorithm-level stoppers) | + +**List form validation**: Metric names in the list must be findable in `evaluate.metrics[]`, otherwise `OptimizeConfigFile._validate_required_metrics_against_evaluate` throws `ValueError` at startup, error message lists "unknown metrics" and "available metrics" checklist. + +### 7.4 `OptimizeResult` + `RoundRecord` Field Table + +Source code: `trpc_agent_sdk/evaluation/_optimize_result.py`. This is the return value of `optimize()`, and also the content of `runs//result.json`. + +> **Important convention**: Both `OptimizeResult` and `RoundRecord` are based on `EvalBaseModel` (`alias_generator=to_camel`). **Python in-memory uses snake_case, all converted to camelCase when serialized to JSON**—use camelCase when indexing `result.json` (`bestPassRate` not `best_pass_rate`), common pitfall. In the table below, the "Field" column uses Python names (snake_case), switch to camelCase when reading JSON. + +#### 7.4.1 `OptimizeResult` Top-Level Fields + +**Core result fields**: + +| Field (snake_case) | Type | Meaning | +| --- | --- | --- | +| `status` | `Literal["SUCCEEDED", "FAILED", "CANCELED"]` | Final status; when `FAILED`, `best_prompts = baseline_prompts` | +| `finish_reason` | Literal | `completed` / `perfect_pass_rate` / `no_improvement` / `error` | +| `stop_reason` | `Optional[StopReason]` | Which stopper triggered (see [§5.4](#54-stop-timing-complete-current-round-before-stopping) for details); `None` when FAILED early stop | +| `error_message` | `str` | Error message when FAILED (default `""`) | +| `algorithm` | `str` | Algorithm name (e.g., `"gepa_reflective"`) | + +**Score fields**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `baseline_pass_rate` | `float` | Pass rate of baseline on valset | +| `best_pass_rate` | `float` | Pass rate of optimal candidate on valset | +| `pass_rate_improvement` | `float` | `best - baseline` | +| `baseline_metric_breakdown` | `dict[str, float]` | Mean score of each metric for baseline | +| `best_metric_breakdown` | `dict[str, float]` | Mean score of each metric for optimal candidate | +| `metric_thresholds` | `dict[str, float]` | Threshold for each metric (copied from `evaluate.metrics[].threshold`) | +| `per_metric_best_candidates` | `dict[str, list[int]]` | Pareto frontier candidate index for each metric (0-based); empty = algorithm does not expose this information | + +**Prompt fields**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `baseline_prompts` | `dict[str, str]` | Starting prompt content (keyed by TargetPrompt field names) | +| `best_prompts` | `dict[str, str]` | Optimal candidate prompts; = `baseline_prompts` when `FAILED` (ensuring artifacts **will never be worse than baseline**) | + +**Round fields**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `total_rounds` | `int` | How many rounds were run | +| `rounds` | `list[RoundRecord]` | Each round's record (see §7.4.2 for details) | + +**Statistics and time fields**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `total_reflection_lm_calls` | `int` | Cumulative reflection LM call count (including retries) | +| `total_token_usage` | `dict[str, int]` | Cumulative tokens for reflection LM: `{prompt, completion, total}` | +| `duration_seconds` | `float` | Total wall-clock duration | +| `started_at` / `finished_at` | `str` | ISO-8601 timestamps | + +**Others**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `schema_version` | `str` | Default `"v1"`; bump when artifact schema upgrades | +| `extras` | `dict[str, Any]` | Custom business fields; optimizer does not read or write | + +#### 7.4.2 `RoundRecord` Fields (One Per Round) + +**Basic round information**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `round` | `int` | 1-based round number | +| `kind` | `Literal["reflective", "merge"]` | Reflection round / fusion round | +| `started_at` | `str` | ISO-8601 timestamp | +| `duration_seconds` | `float` | Wall-clock duration of this round | + +**Rewrite situation**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `optimized_field_names` | `list[str]` | Field names rewritten by reflection LM in this round | +| `candidate_prompts` | `dict[str, str]` | Full field content of this round's candidate | +| `accepted` | `bool` | Whether accepted as new best | +| `acceptance_reason` | `str` | Human-readable explanation of acceptance decision | +| `per_field_diagnosis` | `dict[str, str]` | Diagnosis text given by reflection LM for each field | + +**Scoring situation**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `validation_pass_rate` | `float` | Pass rate of this round on valset | +| `metric_breakdown` | `dict[str, float]` | Mean score of each metric on valset this round; empty = this round did not run valset | +| `failed_case_ids` | `list[str]` | Failed case IDs on valset this round | +| `failed_cases_truncated` | `int` | Number of failed cases cut off due to token budget | +| `train_minibatch_size` | `int` | Minibatch size of this round; 0 = skip, not sampled | +| `train_subsample_parent_score` | `Optional[float]` | Parent candidate's score on minibatch; `None` = not run | +| `train_subsample_candidate_score` | `Optional[float]` | New candidate's score on minibatch; `None` = not run | +| `skip_reason` | `Optional[str]` | Skip reason (e.g., `"subsample perfect"`, `"no proposal"`) | +| `error_message` | `Optional[str]` | Algorithm error message this round | + +**Statistical fields**: + +| Field | Type | Meaning | +| --- | --- | --- | +| `reflection_lm_calls` | `int` | Reflection LM call count this round (including retries) | +| `round_token_usage` | `dict[str, int]` | Reflection LM tokens this round: `{prompt, completion, total}` | +| `budget_used` | `Optional[int]` | Cumulative used metric_calls | +| `budget_total` | `Optional[int]` | Configured budget upper limit (e.g., `max_metric_calls`) | + +**`extras`** (`dict[str, Any]`): Custom business fields; optimizer does not read or write. + +#### 7.4.3 `OptimizeResult` Utility Methods + +| Method | Behavior | +| --- | --- | +| `dump_to(path)` | Serialize to JSON file (`indent=2`, `by_alias=True`) | +| `OptimizeResult.from_file(path)` | classmethod, deserialize from JSON | +| `format_summary(*, output_dir, update_source)` | Generate human-readable text for `summary.txt` | + + +## 8 Artifacts and Directory Conventions + +Each time `optimize()` is run, the framework persists a complete set of audit artifacts under `output_dir`. All writes are **atomic**—SIGINT / process crash will not leave half-written files. + +### 8.1 Directory Layout + +```text +runs// +├── result.json Complete OptimizeResult serialization (programmatic entry) +├── summary.txt Human-readable summary (see baseline → best at a glance) +├── config.snapshot.json Complete snapshot of optimizer.json used this run (reproducible) +├── run.log Single-line status, CI parsing friendly +│ +├── baseline_prompts/ Prompt snapshots before running (one .md per field) +│ ├── system_prompt.md +│ └── ... +│ +├── best_prompts/ Optimal candidate from optimization (one .md per field) +│ ├── system_prompt.md +│ └── ... +│ +└── rounds/ Complete RoundRecord for each round + ├── round_001.json + ├── round_002.json + └── ... +``` + +Role of each file: + +| File / Directory | When Written | What It's For | +| --- | --- | --- | +| `result.json` | Optimization ends (including failure) | Most authoritative artifact for programmatic reading. Complete `OptimizeResult` serialization (see [§7.4](#74-optimizeresult--roundrecord-field-table) for details). **Field names are camelCase** | +| `summary.txt` | Optimization ends (only success) | Human-readable summary: `baseline → best` trend, metric breakdown, all best fields + character count, artifact directory index | +| `config.snapshot.json` | Optimization starts | Complete snapshot of `optimizer.json` used this run—directly use it later when wanting to "re-run this result" | +| `run.log` | Optimization ends | Single line: ` status=... algorithm=... baseline=0.4 best=0.85 delta=+0.45 rounds=10 duration_seconds=120.5`; CI platform grep-friendly | +| `baseline_prompts/.md` | Optimization starts | Content snapshot of each TargetPrompt field before running—**written regardless of `update_source` setting** (most important fallback artifact) | +| `best_prompts/.md` | Optimization ends (only when result exists) | Optimal candidate prompts—when `update_source=False`, this is the most valuable artifact (awaiting manual review and synchronization) | +| `rounds/round_.json` | Each round ends | Complete `RoundRecord` serialization (see [§7.4.2](#742-roundrecord-fields-one-per-round) for details); 3-digit zero-padded numbering for easy sorting | + +### 8.2 Sentinel File: Letting Users Actively Stop Optimization + +Source code: `_optimize_gepa_reflective.py:_build_stop_callbacks` end. + +During optimization, the user manually `touch optimize.stop` under `output_dir`: + +```bash +touch runs//optimize.stop +``` + +The framework detects this file at the beginning of the next round and stops (`gepa.utils.FileStopper` implementation), `stop_reason="user_requested_stop"`. **Typical use case**: discovered it's already sufficient after running halfway / temporarily need to release LLM quota—more elegant than Ctrl+C, ensures current round completes and disk persistence is clean. + +### 8.3 Atomic Disk Persistence Guarantee + +**All artifacts use tmp + `os.replace` atomic write**—POSIX guarantees rename atomicity, when process is kill / power failure, either clean old file or clean new file exists in `output_dir`, **will never appear in half-written state**. + +Source code: Two utility functions in `_agent_optimizer.py`: + +- `_atomic_write_text(path, content)`: First write to `.tmp`, then `os.replace(tmp, path)` +- `_mask_sigint`: Context manager, shields SIGINT during `_persist_artifacts` (avoid "second Ctrl+C interrupts finally disk persistence") + +**Source prompt file write-back when `update_source=True`**: Uses `TargetPrompt.write_all`, also guarantees atomicity for **multi-field**—when any field write fails, all already successfully written fields roll back to pre-call content (see `write_all` contract in [§7.2](#72-targetprompt-api-table) for details). + +> **Extreme fault tolerance**: If `os.replace` itself fails when `update_source=True` writes source files (e.g., target file's directory was concurrently deleted), the framework will **explicitly call `write_all(baseline)` to restore source files to pre-run content**, then re-raise the original exception—ensuring business never gets a "half-optimized" source file. + + +## 9 Want to Extend Yourself? + +Source code main entry: `_optimize_registrations.py`. The framework supports three types of extensions through a **registration mechanism**, no need to fork the SDK. + +### 9.1 Register New Algorithm + +Source code: `_base_optimizer.py:BaseOptimizer` + `_optimize_registry.py:OPTIMIZER_REGISTRY`. + +Write a `BaseOptimizer` subclass, implement `async def run(self, *, reporter=None) -> OptimizeResult`, register to `OPTIMIZER_REGISTRY`: + +```python +from trpc_agent_sdk.evaluation._base_optimizer import BaseOptimizer +from trpc_agent_sdk.evaluation._optimize_registry import OPTIMIZER_REGISTRY +from trpc_agent_sdk.evaluation._optimize_result import OptimizeResult + + +class MyOwnOptimizer(BaseOptimizer): + async def run(self, *, reporter=None) -> OptimizeResult: + # Your algorithm main loop. Base class has already injected: + # self.config - OptimizeConfigFile (including evaluate / optimize two sections) + # self.call_agent - Business agent adapter function + # self.target_prompt - TargetPrompt instance + # self.train_dataset_path / self.validation_dataset_path + # self.callbacks / self.output_dir + # self.extra_stop_callbacks / self.extra_gepa_callbacks + ... + return OptimizeResult(...) + + +# Registration: second parameter must be BaseOptimizer subclass, otherwise register() throws TypeError +OPTIMIZER_REGISTRY.register("my_own_algo", MyOwnOptimizer) +``` + +Business side usage: Change `optimize.algorithm.name` in `optimizer.json` to `"my_own_algo"`, the framework finds your class through `OPTIMIZER_REGISTRY.get(...)` at startup, instantiates it, and runs `run()`. + +**Note**: `GepaReflectiveAlgo.name` is currently `Literal["gepa_reflective"]`—**new algorithms need a new `pydantic.BaseModel` configuration class** (e.g., `MyOwnAlgo`), and modify `OptimizeConfig.algorithm` field to discriminated union (see `_optimize_config.py:OptimizeConfig` docstring for details). + +### 9.2 Register Custom Stopper + +Source code: `AgentOptimizer.optimize`'s `extra_stop_callbacks` parameter in `_agent_optimizer.py`. + +Inject via `extra_stop_callbacks` at runtime—**no need to modify configuration file**: + +```python +from trpc_agent_sdk.evaluation._optimize_gepa_reflective import _LabeledStopper + + +class MySloMonitorStopper: + """Custom stopper: check external SLO monitoring system, stop when threshold is exceeded.""" + + def __init__(self, slo_client): + self._slo = slo_client + self.last_triggered = False + + def __call__(self, gepa_state=None) -> bool: + if self._slo.is_p99_breached(): + self.last_triggered = True + return True + return False + + +# Usage: +stopper = MySloMonitorStopper(slo_client) +result = await AgentOptimizer.optimize( + ..., + extra_stop_callbacks=[ + # Ordinary stopper: stop_reason displays as "completed" + stopper, + + # When wanting stable stop_reason label, use _LabeledStopper wrapper: + # _LabeledStopper(stopper, "slo_breach"), # But "slo_breach" is not in StopReason Literal, pydantic will reject + ], +) +``` + +**Interface contract** (see `_LabeledStopper`): + +- Must have `__call__(self, gepa_state=None) -> bool` method +- `True` means stop +- Should have `last_triggered: bool` attribute for `_classify_stop_reason` to read + +**Two behaviors of `stop_reason`**: + +- Ordinary callable / custom class: `stop_reason` displays as `"completed"` when triggered (gepa doesn't know why you stopped) +- Wrapped with `_LabeledStopper(inner, label)`: `label` must be a legal value of `StopReason` Literal (see `_optimize_result.py`); need to extend Literal type when customizing new label + +### 9.3 Register Custom Evaluation Callback + +Source code: `AgentOptimizer.optimize`'s `extra_gepa_callbacks` parameter in `_agent_optimizer.py`. + +Access gepa internal events through `extra_gepa_callbacks`—typical use: forwarding to dashboard / real-time monitoring metrics. + +```python +class MyDashboardCallback: + def on_proposal_end(self, *args, **kwargs) -> None: + # Report to Grafana / WandB / internal monitoring + ... + + # gepa silently ignores missing methods, just implement part of the protocol methods as needed + + +result = await AgentOptimizer.optimize( + ..., + extra_gepa_callbacks=[MyDashboardCallback()], +) +``` + +**Protocol constraints**: Each callback should implement several methods in `gepa.core.callback.GEPACallback` protocol (`on_iteration_start` / `on_proposal_start` / `on_proposal_end` / `on_valset_breakdown` / ...). **gepa silently ignores missing methods in callback**, so business can only implement those few that they care about. + + +## 10 FAQ + +**Q: Ran once, `bestPassRate` in `result.json` is the same as `baselinePassRate`, `accepted` are all false—is it a bug?** + +Not a bug. Optimization didn't find a candidate better than baseline—`status="SUCCEEDED"` + `finish_reason="no_improvement"` is the typical combination for this situation, `best_prompts` equals `baseline_prompts`. Possible reasons: baseline is already very good, `max_metric_calls` is too small to reach improvement point, training set and validation set have very different distributions, metric noise is too large (recommend increasing `num_runs`). + +--- + +**Q: `update_source=True` crashed during run, were source prompt files corrupted?** + +No. Two layers of protection: (1) When optimization fails (`status="FAILED"`), the framework simply doesn't call `write_all`; (2) Even if `write_all` itself fails, source files are atomically rolled back through tmp + `os.replace` (see [§8.3](#83-atomic-disk-persistence-guarantee) for details). + +--- + +**Q: Can I modify `optimizer.json` mid-run?** + +No. `optimizer.json` is loaded once at startup, subsequent modifications will not be read. Sentinel file `optimize.stop` is the only supported "runtime intervention" (see [§8.2](#82-sentinel-file-letting-users-actively-stop-optimization) for details). + +--- + +**Q: Can I run with a very small training set (< 5 cases)?** + +Yes, but effect is poor: (1) Reflection LM sees too few feedback samples, rewrite direction is unstable; (2) Small training set easily lets advanced configuration overfit (refer to [§4.8](#48)). Recommend at least 5~10 cases; consider manual tuning first when < 5. + +--- + +**Q: How to handle retries when `call_agent` internally sends HTTP / RPC?** + +Handle it yourself within `call_agent`. The framework does not do retries for business at LLM / service call layer—designed to keep `call_agent` as a black box. If the call fails, that case's evaluation score counts as 0, and the reflection LM will see the error message (refer to §5.2 Reflection LM feedback structure). + +--- + +**Q: Can multiple `optimize()` runs happen simultaneously, sharing one `output_dir`?** + +No. Multiple processes writing to one `output_dir`, atomic write constraint protects single files from being half-written, but **multiple processes overwrite files mutually**—`result.json` / `rounds/round_001.json`, etc. will step on each other. Use independent timestamp subdirectory for each run. + +--- + +**Q: When using black-box `call_agent` mode, can I use metrics like `tool_trajectory_avg_score`?** + +No. Black-box `call_agent` mode cannot obtain session traces / tool intermediate_data, the framework will fail-fast and reject at startup (see [§7.1](#71-agentoptimizeroptimize-parameter-table) startup check table for details). Switch to response-level metrics: `final_response_avg_score` / `llm_rubric_response` / `llm_final_response`. + +--- + +**Q: After running with `update_source=False`, source prompts are still in place, but `target_prompt.write_all` was called repeatedly during the process?** + +Yes. The optimizer main loop calls `write_all` every time a new candidate is generated to write the candidate to source files registered with `add_path`—this is to let the next `call_agent` call read the new prompt. **The `finally` phase will automatically `write_all(baseline_snapshot)` to roll back source files to baseline content** (source code: `cleanup_done` sentinel in `optimize` in `_agent_optimizer.py`). So after `update_source=False` finishes running, source files are **completely consistent with before running**—provided that `TargetPrompt.write_all` didn't throw an error during the rollback phase (in extreme cases when it throws an error, the framework will log a warning but will not affect `result.json` / `best_prompts/` artifact production). + +--- + +**Q: How to "re-run" last optimization result?** + +Re-run `runs//config.snapshot.json`—it is the complete configuration snapshot from last time. But LLM output has randomness, even with consistent configuration you may get different best_prompts; fixing the `seed` field can reduce (not eliminate) this randomness. Must lock seed when A/B testing (refer to [§4.8](#48)). diff --git a/docs/mkdocs/en/session.md b/docs/mkdocs/en/session.md new file mode 100644 index 000000000..c75831da3 --- /dev/null +++ b/docs/mkdocs/en/session.md @@ -0,0 +1,960 @@ +# Session Management + +The tRPC-Agent framework provides powerful Session management capabilities for maintaining conversation history and context information during Agent-user interactions. Through automatic persistence of conversation records, intelligent summary compression, and flexible storage backends, session management provides a complete infrastructure for building stateful intelligent Agents. + +## Session Service + +### Overview + +In trpc-agent, `SessionService` is used to manage `Session` (sessions). A `Session` is a collection of multi-turn conversations that stores the interaction records between users and Agents, as well as between Agents. + +#### Session vs Memory + +| Feature | Session | Memory | +|---------|---------|--------| +| **Scope** | Single session | Cross-session (shared across all sessions) | +| **Lifecycle** | Created and destroyed with the session | Independent of sessions, controlled by TTL | +| **Stored Content** | Complete conversation history of the current session | Key events and knowledge fragments | +| **Access Method** | Automatically loaded into context | Retrieved via the `load_memory` tool | +| **Typical Use** | Context of a single conversation | Long-term memory, user profiles, knowledge accumulation | + +--- + +### Core Structure of Session + +Based on the implementation in [trpc_agent_sdk/sessions/_session.py](../../../trpc_agent_sdk/sessions/_session.py), a `Session` contains the following key fields: + +#### 1. Identity + +- **`id`**: Session ID; generating one with UUID is recommended +- **`app_name`**: Identifies which App this conversation belongs to +- **`user_id`**: Identifies which User this conversation belongs to +- **`save_key`**: Format is `{app_name}/{user_id}`, used for storage and retrieval + +#### 2. Conversation Records (Events) + +- **`events`**: A list of `Event` objects, stored in chronological order +- **Event Types**: User messages, Agent responses, tool operations, etc. +- **Event Filtering**: Supports TTL and maximum count limits (`event_ttl_seconds`, `max_events`) + +**Event Filtering Logic** (`_session.py`): +```python +def apply_event_filtering(self, event_ttl_seconds: float = 0.0, max_events: int = 0) -> None: + """Apply event filtering: TTL filtering + count limit""" + # 1. TTL filtering: remove expired events + if event_ttl_seconds > 0: + cutoff_time = time.time() - event_ttl_seconds + self.events = [e for e in self.events if e.timestamp >= cutoff_time] + + # 2. Count limit: keep only the most recent max_events events + if max_events > 0: + if len(self.events) > max_events: + self.events = self.events[-max_events:] + + # 3. Protect the first user message (if all events have been filtered) + # Ensure at least one user message is retained to maintain conversation context integrity +``` + +#### 3. Session State (State) + +- **`state`**: Dictionary type, stores session-related data +- **State Scopes**: + - **Session State**: Session-level state (stored in `session.state`) + - **User State**: User-level state (stored in `SessionService`, key prefix `user:`) + - **App State**: Application-level state (stored in `SessionService`, key prefix `app:`) + - **Temp State**: Temporary state (not persisted, key prefix `temp:`) + +**State Merge Logic** (`_utils.py`): +```python +def extract_state_delta(state_delta: Optional[dict[str, Any]]) -> StateStorageEntry: + """Extract state changes, separated into app, user, and session state""" + # Separate state by key prefix: + # - 'app:' prefix → app_state_delta + # - 'user:' prefix → user_state_delta + # - 'temp:' prefix → ignored (not persisted) + # - others → session_state +``` + +#### 4. Metadata + +- **`last_update_time`**: Last update time (timestamp) +- **`conversation_count`**: Number of conversation turns + +--- + +### Core Features of SessionService + +Based on the implementation in [trpc_agent_sdk/sessions/](../../../trpc_agent_sdk/sessions/), `SessionService` provides the following core features: + +#### 1. Session Management (CRUD) + +**Create Session**: +```python +session = await session_service.create_session( + app_name="my_app", + user_id="user_001", + session_id=str(uuid.uuid4()), # Optional; auto-generated if not provided + state={"initial_key": "initial_value"} # Optional initial state +) +``` + +**Get Session**: +```python +session = await session_service.get_session( + app_name="my_app", + user_id="user_001", + session_id=session_id +) +``` + +**List Sessions**: +```python +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) +``` + +**Delete Session**: +```python +await session_service.delete_session( + app_name="my_app", + user_id="user_001", + session_id=session_id +) +``` + +**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) +- `delete_session`: Deletes a session and its associated data + +--- + +#### 2. Append Event + +**Functionality**: Appends new events to a session, automatically updating state and TTL. + +**Implementation Logic** (`_base_session_service.py`): +```python +async def append_event(self, session: Session, event: Event) -> Event: + """Append an event to the session""" + # 1. Skip partial events + if event.partial: + return event + + # 2. Remove temporary state (temp: prefix) + event = self._trim_temp_delta_state(event) + + # 3. Update session state (session.state) + self.__update_session_state(session, event) + + # 4. Add the event and apply filtering (TTL + max_events) + session.add_event(event, + event_ttl_seconds=self._session_config.event_ttl_seconds, + max_events=self._session_config.max_events) + + # 5. Update storage (app/user state handled by specific implementation) + return event +``` + +**State Update**: +- **Session State**: Directly updates `session.state` +- **User State**: Updates user state in `SessionService` (key prefix `user:`) +- **App State**: Updates application state in `SessionService` (key prefix `app:`) +- **Temp State**: Not persisted, exists only in memory + +--- + +#### 3. Event Filtering + +**Functionality**: Filters events based on TTL and maximum count limits to prevent excessively long context. + +**Configuration** (`SessionServiceConfig`): +```python +from trpc_agent_sdk.sessions import SessionServiceConfig + +session_config = SessionServiceConfig( + event_ttl_seconds=3600, # Event TTL: 1 hour + max_events=100, # Maximum event count: 100 + num_recent_events=10, # Keep the most recent N events (optional) +) +``` + +**Filtering Timing**: +- **On Event Append**: `append_event` automatically applies filtering +- **On Session Retrieval**: `get_session` automatically applies filtering + +**Filtering Logic** (`_session.py`): +1. **TTL Filtering**: Removes events where `timestamp < (now - event_ttl_seconds)` +2. **Count Limit**: Keeps only the most recent `max_events` events +3. **User Message Protection**: If all events are filtered out, at least the first user message is retained + +--- + +#### 4. TTL (Time-To-Live) Cache Eviction + +**Functionality**: Automatically cleans up expired session data to prevent unbounded storage growth. + +**TTL Configuration** (`SessionServiceConfig`): +```python +from trpc_agent_sdk.sessions import SessionServiceConfig + +session_config = SessionServiceConfig( + ttl=SessionServiceConfig.create_ttl_config( + enable=True, # Enable TTL + ttl_seconds=86400, # Session expiration: 24 hours + cleanup_interval_seconds=3600, # Cleanup interval: 1 hour (InMemory/SQL only) + ), +) +``` + +**TTL Refresh Mechanism**: +- **Refresh on Access**: TTL is refreshed when `get_session` is called +- **Refresh on Update**: TTL is refreshed when `append_event` is called +- **Refresh on State Access**: TTL is refreshed when app/user state is accessed + +**Implementation Differences**: +- **InMemorySessionService**: Background periodic cleanup task (`_cleanup_loop`) +- **RedisSessionService**: Native Redis `EXPIRE` mechanism (automatic expiration) +- **SqlSessionService**: Background periodic cleanup task (batch SQL DELETE) + +--- + +#### 5. State Scope Management + +**Functionality**: Supports state storage and access across different scopes. + +**State Scopes** (`_utils.py`): + +| Scope | Prefix | Storage Location | Lifecycle | Example | +|-------|--------|-----------------|-----------|---------| +| **Session State** | No prefix | `session.state` | With session | `{"current_topic": "weather"}` | +| **User State** | `user:` | `SessionService` | Cross-session, user-level | `{"user:name": "Alice"}` | +| **App State** | `app:` | `SessionService` | Cross-session, application-level | `{"app:version": "1.0"}` | +| **Temp State** | `temp:` | Memory | Temporary, not persisted | `{"temp:cache": "..."}` | + +**State Merge** (during `get_session`): +```python +# From _in_memory_session_service.py +async def get_session(...) -> Optional[Session]: + session = self._get_session(app_name, user_id, session_id) + app_state = self._get_app_state(app_name) # Get app state + user_state = self._get_user_state(app_name, user_id) # Get user state + + # Merge state: session.state + user_state + app_state + return self._merge_state(app_state, user_state, session) +``` + +--- + +#### 6. Session Summarization + +**Functionality**: Compresses long conversations into summaries to reduce context length. + +**Configuration** (`SummarizerSessionManager`): +```python +from trpc_agent_sdk.sessions import SummarizerSessionManager, SessionSummarizer + +summarizer = SessionSummarizer(...) +summarizer_manager = SummarizerSessionManager(summarizer=summarizer) + +# Set summarization trigger conditions +set_summarizer_conversation_threshold(summarizer_manager, threshold=10) # Summarize after 10 conversation turns +set_summarizer_events_count_threshold(summarizer_manager, threshold=50) # Summarize after 50 events + +session_service = InMemorySessionService(summarizer_manager=summarizer_manager) +``` + +**Trigger Timing**: +- Conversation turn count reaches the threshold +- Event count reaches the threshold +- Time interval reaches the threshold +- Content length reaches the threshold + +--- + +### SessionService Implementations + +trpc-agent provides three `SessionService` implementations, allowing you to choose the appropriate storage backend based on your scenario: + +#### InMemorySessionService + +**How It Works**: Stores all session data directly in the application's memory. + +**Implementation Details** (based on `_in_memory_session_service.py`): +- **Data Structures**: + - `__sessions`: `dict[app_name, dict[user_id, dict[session_id, SessionWithTTL]]]` + - `__user_state`: `dict[app_name, dict[user_id, StateWithTTL]]` + - `__app_state`: `dict[app_name, StateWithTTL]` +- **Storage Location**: Process memory +- **TTL Mechanism**: Background periodic cleanup task (`_cleanup_loop`) +- **Cleanup Strategy**: Two-phase deletion (collect expired items → batch delete) + +**Persistence**: ❌ **None**. All session data is lost if the application restarts. + +**Applicable Scenarios**: +- ✅ Rapid development +- ✅ Local testing +- ✅ Demo and examples +- ✅ Scenarios that do not require long-term persistence + +**Configuration Example**: +```python +from trpc_agent_sdk.sessions import InMemorySessionService, SessionServiceConfig + +session_config = SessionServiceConfig( + event_ttl_seconds=3600, # Event TTL: 1 hour + max_events=100, # Maximum event count: 100 + ttl=SessionServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # Session expiration: 24 hours + cleanup_interval_seconds=3600, # Cleanup interval: 1 hour + ), +) + +session_service = InMemorySessionService(session_config=session_config) +``` + +**Notes**: +- The cleanup task runs in the background, periodically deleting expired sessions and state +- If `ttl.enable=False`, the cleanup task is not started +- State merge: `get_session` automatically merges app/user/session state + +**Related Examples**: +- 📁 [`examples/session_service_with_in_memory/run_agent.py`](../../../examples/session_service_with_in_memory/run_agent.py) - Complete In-Memory Session Service usage example + +--- + +#### RedisSessionService + +**How It Works**: Uses Redis to store session data, supporting multi-node sharing. + +**Implementation Details** (based on `_redis_session_service.py`): +- **Data Structure**: Redis Hash (stores Session JSON) +- **Storage Location**: External Redis storage +- **Key Format**: + - Session: `session:{app_name}:{user_id}:{session_id}` + - User state: `user_state:{app_name}:{user_id}` + - App state: `app_state:{app_name}` +- **TTL Mechanism**: Native Redis `EXPIRE` command (automatic expiration) +- **TTL Refresh**: Automatically refreshed on access and update + +**Persistence**: ✅ **Yes**. Data is persisted to Redis; sessions can be recovered after application restart. + +**Applicable Scenarios**: +- ✅ Production environments +- ✅ Multi-node deployments +- ✅ High-performance caching requirements +- ✅ Distributed applications + +**Configuration Example**: +```python +from trpc_agent_sdk.sessions import RedisSessionService, SessionServiceConfig +import os + +# Read Redis configuration from environment variables +db_host = os.environ.get("REDIS_HOST", "127.0.0.1") +db_port = os.environ.get("REDIS_PORT", "6379") +db_password = os.environ.get("REDIS_PASSWORD", "") +db_db = os.environ.get("REDIS_DB", 0) + +# Build the Redis connection URL +if db_password: + db_url = f"redis://:{db_password}@{db_host}:{db_port}/{db_db}" +else: + db_url = f"redis://{db_host}:{db_port}/{db_db}" + +session_config = SessionServiceConfig( + event_ttl_seconds=3600, + max_events=100, + ttl=SessionServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # 24-hour expiration (handled automatically by Redis) + ), +) + +session_service = RedisSessionService( + db_url=db_url, + is_async=True, # Use async mode (recommended) + session_config=session_config, + **kwargs # Other Redis connection parameters +) +``` + +**Redis Data Structure**: +```bash +# Session storage (Redis Hash) +session:weather_app:user_001:session_123 + └─ Field: JSON-serialized Session object + +# TTL setting +EXPIRE session:weather_app:user_001:session_123 86400 # Expires after 24 hours +``` + +**Notes**: +- When `is_async=True`, an async Redis client is used, which is concurrency-friendly +- When `is_async=False`, a synchronous Redis client is used +- Redis `EXPIRE` mechanism automatically handles expired keys, **no background cleanup task is needed** +- The `cleanup_interval_seconds` parameter has no effect on RedisSessionService (Redis handles expiration natively) + +**Related Examples**: +- 📁 [`examples/session_service_with_redis/run_agent.py`](../../../examples/session_service_with_redis/run_agent.py) - Complete Redis Session Service usage example + +--- + +#### SqlSessionService + +**How It Works**: Stores all session data in a relational database (MySQL/PostgreSQL). + +**Implementation Details** (based on `_sql_session_service.py`): +- **Data Structure**: SQL tables + - `sessions` table: Stores session metadata + - `events` table: Stores session events (foreign key association) +- **Storage Location**: MySQL/PostgreSQL database +- **TTL Mechanism**: Background periodic cleanup task (batch SQL DELETE) +- **Cleanup Strategy**: One SQL `DELETE` statement batch-removes expired sessions (associated events are removed via foreign-key cascade) + +**Persistence**: ✅ **Yes**. Data is persisted to the database; sessions can be recovered after application restart. + +**Applicable Scenarios**: +- ✅ Production environments +- ✅ Transaction safety requirements +- ✅ Complex queries and statistical analysis +- ✅ Data persistence and backup requirements + +**Configuration Example**: +```python +from trpc_agent_sdk.sessions import SqlSessionService, SessionServiceConfig +import os + +# Read MySQL configuration from environment variables +db_user = os.environ.get("MYSQL_USER", "root") +db_password = os.environ.get("MYSQL_PASSWORD", "") +db_host = os.environ.get("MYSQL_HOST", "127.0.0.1") +db_port = os.environ.get("MYSQL_PORT", "3306") +db_name = os.environ.get("MYSQL_DB", "trpc_agent") + +# Build the database connection URL +# Synchronous operations (pymysql) +db_url = f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + +# Asynchronous operations (aiomysql) +# db_url = f"mysql+aiomysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}?charset=utf8mb4" + +session_config = SessionServiceConfig( + event_ttl_seconds=3600, + max_events=100, + ttl=SessionServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, # 24-hour expiration + cleanup_interval_seconds=3600, # Cleanup every 1 hour + ), +) + +session_service = SqlSessionService( + db_url=db_url, + is_async=True, # Use async mode (recommended) + session_config=session_config, + pool_pre_ping=True, # Connection health check (recommended) + pool_recycle=3600, # Connection recycle time: 1 hour +) +``` + +**Database Table Schema**: +```sql +-- sessions table: stores session metadata +CREATE TABLE sessions ( + app_name VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + id VARCHAR(255) NOT NULL, + state JSON, + conversation_count INT DEFAULT 0, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (app_name, user_id, id), + INDEX idx_update_time (update_time) -- Used for cleanup task +); + +-- events table: stores session events +CREATE TABLE events ( + id VARCHAR(255) NOT NULL, + app_name VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + session_id VARCHAR(255) NOT NULL, + invocation_id VARCHAR(255), + author VARCHAR(255), + content JSON, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- ... other fields + PRIMARY KEY (id, app_name, user_id, session_id), + FOREIGN KEY (app_name, user_id, session_id) + REFERENCES sessions(app_name, user_id, id) + ON DELETE CASCADE, -- Cascade delete + INDEX idx_timestamp (timestamp) -- Used for event filtering +); +``` + +**Cleanup Task** (batch deletion): +```python +# From _sql_session_service.py +async def _cleanup_expired_async(self) -> None: + """Batch delete expired sessions and events""" + expire_before = datetime.now() - timedelta(seconds=self._session_config.ttl.ttl_seconds) + + # Single SQL DELETE for batch deletion (cascades to events) + DELETE FROM sessions + WHERE update_time < expire_before; +``` + +**Notes**: +- When `is_async=True`, the `aiomysql` driver is used; install with: `pip install aiomysql` +- When `is_async=False`, the `pymysql` driver is used; install with: `pip install pymysql` +- `pool_pre_ping=True` is recommended to avoid stale connections +- `pool_recycle=3600` sets the connection recycle time to avoid long-lived connections +- The cleanup task uses batch SQL DELETE for performance optimization +- Foreign key cascade delete: deleting a session automatically deletes associated events + +**Related Examples**: +- 📁 [`examples/session_service_with_sql/run_agent.py`](../../../examples/session_service_with_sql/run_agent.py) - Complete SQL Session Service usage example + +--- + +### Comparison of the Three Implementations + +| Feature | InMemorySessionService | RedisSessionService | SqlSessionService | +|---------|----------------------|-------------------|------------------| +| **Data Storage** | Process memory | External Redis storage | MySQL/PostgreSQL | +| **Persistence** | ❌ Lost on process restart | ✅ Persisted to Redis | ✅ Persisted to database | +| **Distributed** | ❌ Cannot share across processes | ✅ Supports cross-process/server | ✅ Supports cross-process/server | +| **TTL Mechanism** | ✅ Periodic cleanup task | ✅ **Native Redis expiration** | ✅ **Periodic cleanup task (batch)** | +| **Cleanup Efficiency** | ⭐⭐⭐ Requires scanning | ⭐⭐⭐⭐⭐ Native Redis | ⭐⭐⭐⭐ **Single SQL batch delete** | +| **Transaction Support** | ❌ | ❌ | ✅ **ACID transactions** | +| **Complex Queries** | ❌ | ❌ | ✅ **SQL queries** | +| **State Management** | ✅ In-memory dictionary | ✅ Redis Hash | ✅ SQL tables | +| **Event Storage** | ✅ In-memory list | ✅ Redis Hash | ✅ SQL tables (foreign key association) | +| **Deployment Scenario** | Local dev / single node | Production / distributed / caching | Production / distributed / relational data | +| **Performance** | ⭐⭐⭐⭐⭐ Extremely fast | ⭐⭐⭐⭐ Fast | ⭐⭐⭐ Moderate | + +**Selection Guide**: +- **Development & Testing** → `InMemorySessionService` (zero dependencies, quick startup) +- **Production (High Performance)** → `RedisSessionService` (native Redis expiration, no background tasks) +- **Production (Transactions/Queries)** → `SqlSessionService` (transaction safety, supports complex queries) + +--- + +### Usage Examples + +#### Basic Usage Flow + +```python +import uuid +from trpc_agent_sdk.sessions import InMemorySessionService, SessionServiceConfig +from trpc_agent_sdk.runners import Runner + +# 1. Create SessionService +session_config = SessionServiceConfig( + event_ttl_seconds=3600, # Event TTL: 1 hour + max_events=100, # Maximum event count: 100 + ttl=SessionServiceConfig.create_ttl_config( + enable=True, + ttl_seconds=86400, + cleanup_interval_seconds=3600, + ), +) +session_service = InMemorySessionService(session_config=session_config) + +# 2. Create Runner and configure SessionService +runner = Runner( + app_name="my_app", + agent=my_agent, + session_service=session_service +) + +# 3. Run Agent (if the Session does not exist, the framework creates one automatically) +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, # Optional; auto-generated if not provided + new_message=user_message +): + # Process events... + pass +``` + +#### Manual Session Management + +```python +import uuid +from trpc_agent_sdk.sessions import InMemorySessionService + +session_service = InMemorySessionService() + +app_name = "SessionTest" +user_id = "Alice" +session_id = str(uuid.uuid4()) + +# Create Session +session = await session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + state={"initial_key": "initial_value"} # Optional initial state +) + +# Get Session +session = await session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id +) + +# List existing Sessions +session_list = await session_service.list_sessions( + app_name=app_name, + user_id=user_id +) +print(f"Session: {session_list.sessions}") + +# Delete Session +await session_service.delete_session( + app_name=app_name, + user_id=user_id, + session_id=session_id +) +``` + +#### State Scope Usage + +```python +# Session State (session-level) +session.state["current_topic"] = "weather" + +# User State (user-level, cross-session) +event.actions.state_delta = { + "user:name": "Alice", # User-level state + "user:preference": "dark" # User preference +} + +# App State (application-level, cross-session) +event.actions.state_delta = { + "app:version": "1.0", # Application version + "app:config": {...} # Application configuration +} + +# Temp State (temporary state, not persisted) +event.actions.state_delta = { + "temp:cache": "..." # Temporary cache, not stored +} +``` + +--- + +### Notes + +#### 1. Automatic Session Creation + +**When you call `Runner.run_async`, if no session was created beforehand with `create_session`, the framework creates a Session automatically**. + +```python +# No manual creation needed; the framework creates one automatically +async for event in runner.run_async( + user_id=user_id, + session_id=session_id, # Optional + new_message=user_message +): + pass +``` + +#### 2. Event Filtering + +- `event_ttl_seconds`: Event TTL; expired events are automatically deleted +- `max_events`: Maximum event count; the oldest events are deleted when the limit is exceeded +- Filtering protects the first user message to ensure conversation context integrity + +#### 3. TTL Configuration + +- `ttl_seconds`: Session expiration time (in seconds) +- `cleanup_interval_seconds`: Cleanup interval (InMemory/SQL only; Redis handles expiration natively) +- TTL is automatically refreshed on access and update, extending the session's validity + +#### 4. State Scopes + +- **Session State**: Stored in `session.state`, follows the session lifecycle +- **User State**: Stored in `SessionService`, key prefix `user:`, shared across sessions +- **App State**: Stored in `SessionService`, key prefix `app:`, shared across sessions +- **Temp State**: Not persisted, key prefix `temp:`, exists only in memory + +#### 5. Concurrency Safety + +- `InMemorySessionService`: Thread-safe within a single process +- `RedisSessionService`: Supports multi-process/multi-server concurrency +- `SqlSessionService`: Supports multi-process/multi-server concurrency (using database transactions) + +--- + +### Related Examples + +The following examples demonstrate the usage of different SessionService implementations: + +#### InMemorySessionService + +📁 **Example Path**: [examples/session_service_with_in_memory/](../../../examples/session_service_with_in_memory/) + +**Description**: +- Demonstrates basic usage of In-Memory Session Service +- Shows session creation, retrieval, listing, and deletion +- Demonstrates event appending and filtering +- Demonstrates state scopes (Session/User/App State) + +**How to Run**: +```bash +cd examples/session_service_with_in_memory/ +python3 run_agent.py +``` + +--- + +#### RedisSessionService + +📁 **Example Path**: [examples/session_service_with_redis/](../../../examples/session_service_with_redis/) + +**Description**: +- Demonstrates Redis Session Service usage +- Shows the Redis automatic expiration mechanism +- Provides detailed Redis operation guide +- Includes output analysis and Redis command examples + +**How to Run**: +```bash +cd examples/session_service_with_redis/ +python3 run_agent.py +``` + +--- + +#### SqlSessionService + +📁 **Example Path**: [examples/session_service_with_sql/](../../../examples/session_service_with_sql/) + +**Description**: +- Demonstrates SQL Session Service usage +- Shows MySQL table schema and data operations +- Demonstrates batch cleanup tasks +- Provides MySQL operation commands and output analysis + +**How to Run**: +```bash +cd examples/session_service_with_sql/ +python3 run_agent.py +``` + +--- + +### Core Feature Summary + +#### 1. Session Management (CRUD) + +- ✅ Create, retrieve, list, and delete sessions +- ✅ Automatic session creation (during Runner execution) +- ✅ Session listing excludes events (reduces data transfer) + +#### 2. Event Management + +- ✅ Append events to sessions +- ✅ Event filtering (TTL + maximum count) +- ✅ User message protection (ensures context integrity) + +#### 3. State Management + +- ✅ Multi-scope state (Session/User/App/Temp) +- ✅ Automatic state merge (during `get_session`) +- ✅ State persistence (User/App state shared across sessions) + +#### 4. TTL Cache Eviction + +- ✅ Automatic cleanup of expired sessions to prevent unbounded storage growth +- ✅ Automatic TTL refresh on access and update +- ✅ Different cleanup mechanisms for different implementations + +#### 5. Session Summarization + +- ✅ Supports session summarization (compresses long conversations) +- ✅ Configurable trigger conditions (turn count, event count, time interval, etc.) + +#### 6. Flexible Storage Backends + +- ✅ Supports three implementations: In-Memory, Redis, and SQL +- ✅ Supports TRPC Redis integration +- ✅ Choose the appropriate implementation based on your scenario + +--- + +### Summary + +SessionService provides powerful session management capabilities: + +- ✅ **Session Management**: Complete CRUD operations +- ✅ **Event Management**: Append, filter, and protect user messages +- ✅ **State Management**: Multi-scope state (Session/User/App/Temp) +- ✅ **TTL Eviction**: Automatic cleanup of expired sessions +- ✅ **Session Summarization**: Compress long conversations to reduce context length +- ✅ **Multiple Implementations**: In-Memory, Redis, SQL, TRPC Redis + +By properly using SessionService, you can achieve: +- Multi-turn conversation context management +- User state persistence +- Application-level state sharing +- Session lifecycle management + +For more detailed usage examples, refer to the related examples in the [examples/](../../../examples/) directory. + +- [examples/session_service_with_in_memory/run_agent.py](../../../examples/session_service_with_in_memory/run_agent.py) +- [examples/session_service_with_redis/run_agent.py](../../../examples/session_service_with_redis/run_agent.py) +- [examples/session_service_with_sql/run_agent.py](../../../examples/session_service_with_sql/run_agent.py) + +--- + +## State + +In trpc_agent, **State** is a key-value collection associated with each Session. It can be thought of as the session's "notepad", storing dynamic information that the Agent needs to remember and reference during conversations, enabling the Agent to perceive key contextual information within the session. + +For example, the following information can be stored via State: +- User information: Remember user preferences (e.g., `user_theme: 'dark'`) +- Task progress: Track multi-step task status (e.g., `booking_step: 'confirm_payment'`) +- Information accumulation: Build lists or summaries (e.g., `shopping_cart: ['book', 'pen']`) +- Intermediate results: Pass processing results between Agents (e.g., `analysis_result: '...'`) + +### Features + +#### Data Structure + +State is stored in key-value form within the Session, which implies the following constraints: +- Keys must be strings +- Values must be serializable to strings by default (since they will be injected into the Agent's Prompt) +- Persistence: + - If using InMemorySessionService, State is lost after process restart + - If using RedisSessionService, State is persisted to Redis and can be recovered after process restart/scaling + +#### Scope Control + +State keys use different prefixes to distinguish different levels of session information. + +##### No Prefix (Session State) +- Scope: Current session +- Lifecycle: Follows the Session lifecycle +- Typical use: Task progress, temporary computation results +- Example: `current_step: 'processing'` + +##### `user:` Prefix (User State) +- Scope: All sessions of a specific user +- Lifecycle: Persisted across sessions +- Typical use: User preferences, personal information +- Example: `user:language: 'zh'`, `user:name: 'Zhang San'` + +##### `app:` Prefix (App State) +- Scope: All users and sessions of the entire application +- Lifecycle: Globally persisted +- Typical use: Global configuration, shared resources +- Example: `app:version: '1.0'`, `app:maintenance_mode: false` + +##### `temp:` Prefix (Temporary State) +- Scope: A single run_async invocation +- Lifecycle: Never persisted, discarded after processing +- Typical use: Intermediate computation results, debug information +- Example: `temp:api_response: {...}` + +### Usage + +#### Template Reference: Using in Instructions + +Reference state variables in the Agent's `instruction` using the `{key}` syntax: + +```python +LlmAgent( + name="personalized_assistant", + model="deepseek-chat", + instruction="""Hello {user:name}! + +Current task progress: {current_step} +User preferred language: {user:language} + +Provide assistance for {user:name} based on user preferences.""", +) +``` + +#### Multi-Agent Collaboration: Using output_key + +An Agent can automatically save its output to a state variable: + +```python +# Agent 1: Analyze user requirements +analyzer = LlmAgent( + name="need_analyzer", + model="deepseek-chat", + instruction="Analyze user requirements and provide a detailed analysis", + output_key="analysis_result" # Output saved to state +) + +# Agent 2: Develop a plan based on the analysis result +planner = LlmAgent( + name="solution_planner", + model="deepseek-chat", + instruction="Develop a solution based on the analysis result:\n\n{analysis_result}", + output_key="solution_plan" +) +``` + +#### Modifying in Tools: Using InvocationContext + +Modify state in tool functions via `tool_context.state`: + +**Note: You must use `tool_context` as the parameter name; using a different name will cause errors** + +```python +async def update_user_preference(preference: str, value: str, + tool_context: InvocationContext) -> str: + """Update user preference settings""" + # Save user-level preference + tool_context.state[f"user:{preference}"] = value + + # Record operation history (session-level) + history = tool_context.state.get("preference_history", []) + history.append(f"Updated {preference} to {value}") + tool_context.state["preference_history"] = history + + return f"Preference {preference} = {value} has been updated" +``` + +#### Setting State Before and After Agent Execution + +You can set initial state via `session_service` before and after Agent execution: + +```python +# Set initial state before Agent execution +session = await session_service.create_session( + app_name="my_app", + user_id="user123", + session_id="session456", + state={ + "user:name": "Zhang San", + "user:language": "Chinese", + "current_step": "started", + "app:version": "1.0.0" + } +) + +# runner.run_async(...) + +# Session objects are read-only; call get_session again to read the latest state after a run +session = await session_service.get_session(app_name="my_app", user_id="user123", session_id="session456") +print(session.state) +# After Agent execution, you can modify the state, but it must be updated to session_service to take effect +session.state["current_step"] = "end" +await session_service.update_session(session) +``` + +### Complete Example + +See the complete State usage example: [examples/session_state/run_agent.py](../../../examples/session_state/run_agent.py) diff --git a/docs/mkdocs/en/session_redis.md b/docs/mkdocs/en/session_redis.md new file mode 100644 index 000000000..f8fec13fc --- /dev/null +++ b/docs/mkdocs/en/session_redis.md @@ -0,0 +1,1257 @@ +# RedisStorage Usage Guide + +This document explains in detail how to use the `RedisStorage` class for Redis database operations, including strings, hashes, lists, sets, sorted sets, and other common data types. + +## Overview + +`RedisStorage` is an async/sync Redis storage implementation based on redis-py, providing a unified interface for handling various Redis data operations. + +## Core Components + +### 1. RedisStorage Class +The primary storage class that provides Redis connection and operation interfaces. + +### 2. Helper Classes +- `RedisCommand`: Used to construct Redis commands, e.g., SET, GET, HSET, etc. +- `RedisExpire`: Used for Redis expiration time operation commands, setting expiration policies via the `Ttl` object +- `RedisCondition`: A condition filtering class used for queries +- `Ttl`: Used to configure TTL (Time-To-Live) expiration time, containing fields such as `ttl_seconds` (expiration in seconds), `enable` (whether enabled), etc. + +## Prerequisites + +### 1. Install Required Dependencies + +```bash +# Core dependencies +pip install redis + +# Async support (optional, recommended) +pip install redis[hiredis] +``` + +### 2. Redis Server Setup + +#### Start Redis with Docker +```bash +# Basic startup +docker run -d -p 6379:6379 redis:latest + +# Start with password +docker run -d -p 6379:6379 redis:latest redis-server --requirepass mypassword + +# Persistent data +docker run -d -p 6379:6379 -v redis-data:/data redis:latest redis-server --appendonly yes +``` + +#### Install Redis Locally +```bash +# Ubuntu/Debian +sudo apt-get install redis-server + +# CentOS/RHEL +sudo yum install redis + +# macOS +brew install redis + +# Start service +redis-server +``` + +#### Redis Configuration Optimization +```bash +# Edit redis.conf +# Set maximum memory +maxmemory 2gb +maxmemory-policy allkeys-lru + +# Enable persistence +save 900 1 +save 300 10 +save 60 10000 + +# Network configuration +bind 127.0.0.1 +port 6379 +timeout 300 +``` + +## Basic Usage + +### 1. Initialize RedisStorage + +```python +from trpc_agent_sdk.storage import RedisStorage + +# Async mode (recommended) +storage = RedisStorage( + redis_url="redis://localhost:6379/0", + is_async=True, + decode_responses=True, + max_connections=10, + socket_timeout=5, + socket_connect_timeout=5, + retry_on_timeout=True, + health_check_interval=30 +) + +# Sync mode +storage = RedisStorage( + redis_url="redis://localhost:6379/0", + is_async=False, + decode_responses=True +) + +# Connect with password +storage = RedisStorage( + redis_url="redis://:password@localhost:6379/0", + is_async=True +) +``` + +### 2. Define Redis Commands + +```python +from trpc_agent_sdk.storage import RedisCommand, RedisExpire, RedisCondition +from trpc_agent_sdk.types import Ttl + +# String operation command +set_command = RedisCommand( + method="set", + args=("user:1", "{'name': 'Alice', 'age': 30}"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) # Expires after 1 hour +) + +get_command = RedisCommand( + method="get", + args=("user:1",) +) + +# Hash operation command +hset_command = RedisCommand( + method="hset", + args=("user_profile:1", "name", "Alice"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=7200)) # Expires after 2 hours +) + +hgetall_command = RedisCommand( + method="hgetall", + args=("user_profile:1",) +) +``` + +### 3. Basic Operation Example + +```python +import asyncio +import json +from trpc_agent_sdk.storage import RedisStorage, RedisCommand, RedisExpire, RedisCondition +from trpc_agent_sdk.types import Ttl + +async def basic_example(): + # Initialize storage + storage = RedisStorage( + redis_url="redis://localhost:6379/0", + is_async=True + ) + + try: + # Create Redis connection pool + await storage.create_redis_engine() + + # Use Redis session + async with storage.create_db_session() as conn: + # Set string value + user_data = {"name": "Alice", "age": 30, "email": "alice@example.com"} + set_command = RedisCommand( + method="set", + args=("user:1", json.dumps(user_data)), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) + ) + await storage.add(conn, set_command) + + print("User data saved successfully") + + # Get string value + get_command = RedisCommand(method="get", args=("user:1",)) + result = await storage.get(conn, get_command) + + if result: + retrieved_data = json.loads(result.decode('utf-8')) + print(f"Retrieved user: {retrieved_data}") + + # Query keys matching a pattern + condition = RedisCondition(limit=10) + keys = await storage.query(conn, "user:*", condition) + print(f"Found {len(keys)} user keys") + + # Delete key + await storage.delete(conn, "user:1") + print("User data deleted") + + finally: + await storage.close() + +# Run example +asyncio.run(basic_example()) +``` + +### 4. Configuration Management + +```python +import os +from dataclasses import dataclass +from typing import Dict, Any, Optional + +@dataclass +class RedisConfig: + """Redis configuration class""" + host: str = "localhost" + port: int = 6379 + db: int = 0 + password: Optional[str] = None + + # Connection pool settings + max_connections: int = 10 + socket_timeout: int = 5 + socket_connect_timeout: int = 5 + retry_on_timeout: bool = True + health_check_interval: int = 30 + + # Redis settings + decode_responses: bool = True + + def get_redis_url(self) -> str: + """Get Redis connection URL""" + if self.password: + return f"redis://:{self.password}@{self.host}:{self.port}/{self.db}" + return f"redis://{self.host}:{self.port}/{self.db}" + + def get_connection_kwargs(self) -> Dict[str, Any]: + """Get connection parameters""" + return { + "decode_responses": self.decode_responses, + "max_connections": self.max_connections, + "socket_timeout": self.socket_timeout, + "socket_connect_timeout": self.socket_connect_timeout, + "retry_on_timeout": self.retry_on_timeout, + "health_check_interval": self.health_check_interval, + } + + @classmethod + def from_env(cls) -> 'RedisConfig': + """Create configuration from environment variables""" + return cls( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", "6379")), + db=int(os.getenv("REDIS_DB", "0")), + password=os.getenv("REDIS_PASSWORD"), + max_connections=int(os.getenv("REDIS_MAX_CONNECTIONS", "10")), + socket_timeout=int(os.getenv("REDIS_SOCKET_TIMEOUT", "5")), + ) + +# Usage +config = RedisConfig.from_env() +storage = RedisStorage( + redis_url=config.get_redis_url(), + is_async=True, + **config.get_connection_kwargs() +) +``` + +### 5. Environment Variable Configuration + +Set environment variables to configure the Redis connection: + +```bash +# Basic connection configuration +export REDIS_HOST=localhost +export REDIS_PORT=6379 +export REDIS_DB=0 +export REDIS_PASSWORD=your_password + +# Connection pool configuration +export REDIS_MAX_CONNECTIONS=20 +export REDIS_SOCKET_TIMEOUT=10 +export REDIS_CONNECT_TIMEOUT=5 + +# Other configuration +export REDIS_DECODE_RESPONSES=true +export REDIS_RETRY_ON_TIMEOUT=true +``` + +## RedisStorage Interface Details + +### 1. Core Interfaces + +#### Redis Engine Management +```python +# Create Redis connection pool +await storage.create_redis_engine() + +# Close Redis connection +await storage.close() +``` + +#### Session Management +```python +# Create Redis session (recommended to use context manager) +async with storage.create_db_session() as conn: + # Execute Redis operations here + pass + +# Create raw session (requires manual management) +conn = await storage.create_redis_session() +``` + +### 2. CRUD Operations + +#### Add Data +```python +async with storage.create_db_session() as conn: + # String operation + command = RedisCommand( + method="set", + args=("key", "value"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) + ) + await storage.add(conn, command) + + # Hash operation + hash_command = RedisCommand( + method="hset", + args=("hash_key", "field", "value") + ) + await storage.add(conn, hash_command) +``` + +#### Get Data +```python +async with storage.create_db_session() as conn: + # Get string + get_command = RedisCommand(method="get", args=("key",)) + result = await storage.get(conn, get_command) + + # Get hash + hget_command = RedisCommand(method="hgetall", args=("hash_key",)) + hash_result = await storage.get(conn, hget_command) +``` + +#### Query Data +```python +async with storage.create_db_session() as conn: + # Simple query + condition = RedisCondition(limit=10) + keys = await storage.query(conn, "user:*", condition) + + # Unlimited query + all_condition = RedisCondition(limit=-1) + all_keys = await storage.query(conn, "*", all_condition) +``` + +#### Delete Data +```python +async with storage.create_db_session() as conn: + # Delete a single key + await storage.delete(conn, "key") + + # Delete multiple keys (query first, query returns list[tuple[str, Any]]) + condition = RedisCondition(limit=-1) + keys_to_delete = await storage.query(conn, "temp:*", condition) + for key, _value in keys_to_delete: + await storage.delete(conn, key) +``` + +#### Commit and Refresh +```python +async with storage.create_db_session() as conn: + # Redis operations are atomic by default, commit is mainly for compatibility + await storage.commit(conn) + + # Refresh data (re-fetch) + command = RedisCommand(method="get", args=("key",)) + await storage.refresh(conn, command) +``` + +### 3. Advanced Features + +#### Dynamic Command Execution +```python +# Dynamically invoke Redis commands via __getattr__ (auto-manages connection, no need to pass conn manually) +# Note: Methods already defined in RedisStorage (e.g., get, add, delete) will not trigger dynamic invocation +await storage.set("key", "value", ex=300) +info = await storage.info() + +# Execute arbitrary commands using execute_command (requires manually passing conn) +async with storage.create_db_session() as conn: + command = RedisCommand(method="ping") + pong = await storage.execute_command(conn, command) +``` + +#### Pipeline Operations +```python +async with storage.create_db_session() as conn: + # Batch operations (simulated pipeline) + commands = [ + RedisCommand(method="set", args=("key1", "value1")), + RedisCommand(method="set", args=("key2", "value2")), + RedisCommand(method="set", args=("key3", "value3")) + ] + + for command in commands: + await storage.add(conn, command) + + await storage.commit(conn) +``` + +## Redis Connection URL + +### Basic Connection Format +```python +# Basic connection +redis_url = "redis://localhost:6379/0" + +# Connect with password +redis_url = "redis://:password@localhost:6379/0" + +# Full format +redis_url = "redis://username:password@host:port/database" + +# SSL connection +redis_url = "rediss://:password@host:6380/0" + +# Unix Socket connection +redis_url = "unix:///path/to/redis.sock?db=0" +``` + +### Connection Parameter Examples +```python +# Development environment +dev_url = "redis://localhost:6379/0" + +# Testing environment +test_url = "redis://:test_password@redis-test:6379/1" + +# Production environment +prod_url = "redis://:prod_password@redis-cluster:6379/0" + +# With connection pool parameters +storage = RedisStorage( + redis_url=prod_url, + is_async=True, + max_connections=20, + socket_timeout=10, + socket_connect_timeout=5, + retry_on_timeout=True, + health_check_interval=30 +) +``` + +## Redis Data Type Operations in Detail + +### 1. String Operations + +#### Basic String Operations +```python +async with storage.create_db_session() as conn: + # Set string + set_command = RedisCommand( + method="set", + args=("user:1", "Alice"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) + ) + await storage.add(conn, set_command) + + # Get string + get_command = RedisCommand(method="get", args=("user:1",)) + result = await storage.get(conn, get_command) + print(f"User: {result.decode('utf-8')}") + + # Set multiple key-value pairs (mset is not in the methods supported by add, use execute_command instead) + mset_command = RedisCommand( + method="mset", + args=({"user:1": "Alice", "user:2": "Bob"},), + ) + await storage.execute_command(conn, mset_command) + + # Get multiple values + mget_command = RedisCommand(method="mget", args=(["user:1", "user:2"])) + results = await storage.get(conn, mget_command) +``` + +#### JSON String Operations +```python +import json + +async with storage.create_db_session() as conn: + # Store JSON data + user_data = {"name": "Alice", "age": 30, "email": "alice@example.com"} + json_command = RedisCommand( + method="set", + args=("user:profile:1", json.dumps(user_data)), + expire=RedisExpire(ttl=Ttl(ttl_seconds=7200)) + ) + await storage.add(conn, json_command) + + # Get and parse JSON data + get_command = RedisCommand(method="get", args=("user:profile:1",)) + result = await storage.get(conn, get_command) + if result: + user_profile = json.loads(result.decode('utf-8')) + print(f"User profile: {user_profile}") +``` + +### 2. Hash Operations + +```python +async with storage.create_db_session() as conn: + # Set hash field + hset_command = RedisCommand( + method="hset", + args=("user:1", "name", "Alice"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) + ) + await storage.add(conn, hset_command) + + # Set multiple hash fields + hmset_command = RedisCommand( + method="hmset", + args=("user:1", {"age": "30", "email": "alice@example.com"}) + ) + await storage.add(conn, hmset_command) + + # Get a single hash field + hget_command = RedisCommand(method="hget", args=("user:1", "name")) + name = await storage.get(conn, hget_command) + + # Get all hash fields + hgetall_command = RedisCommand(method="hgetall", args=("user:1",)) + user_data = await storage.get(conn, hgetall_command) + print(f"User data: {user_data}") + + # Get multiple hash fields + hmget_command = RedisCommand(method="hmget", args=("user:1", ["name", "age"])) + fields = await storage.get(conn, hmget_command) +``` + +### 3. List Operations + +```python +async with storage.create_db_session() as conn: + # Push element to the left of the list + lpush_command = RedisCommand( + method="lpush", + args=("activities", "User logged in"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=1800)) + ) + await storage.add(conn, lpush_command) + + # Push element to the right of the list + rpush_command = RedisCommand( + method="rpush", + args=("activities", "User updated profile") + ) + await storage.add(conn, rpush_command) + + # Get list range + lrange_command = RedisCommand(method="lrange", args=("activities", 0, -1)) + activities = await storage.execute_command(conn, lrange_command) + print(f"Activities: {activities}") + + # Get list length + llen_command = RedisCommand(method="llen", args=("activities",)) + length = await storage.execute_command(conn, llen_command) + + # Pop element + lpop_command = RedisCommand(method="lpop", args=("activities",)) + popped = await storage.execute_command(conn, lpop_command) +``` + +### 4. Set Operations + +```python +async with storage.create_db_session() as conn: + # Add set members + sadd_command = RedisCommand( + method="sadd", + args=("active_users", "alice", "bob", "charlie"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=900)) + ) + await storage.add(conn, sadd_command) + + # Get all set members + smembers_command = RedisCommand(method="smembers", args=("active_users",)) + members = await storage.execute_command(conn, smembers_command) + print(f"Active users: {members}") + + # Check if member exists + sismember_command = RedisCommand(method="sismember", args=("active_users", "alice")) + is_member = await storage.execute_command(conn, sismember_command) + + # Get set size + scard_command = RedisCommand(method="scard", args=("active_users",)) + size = await storage.execute_command(conn, scard_command) + + # Set operations + sinter_command = RedisCommand(method="sinter", args=(["set1", "set2"])) + intersection = await storage.execute_command(conn, sinter_command) +``` + +### 5. Sorted Set Operations + +```python +async with storage.create_db_session() as conn: + # Add sorted set members + zadd_command = RedisCommand( + method="zadd", + args=("leaderboard", {"alice": 100, "bob": 85, "charlie": 92}), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) + ) + await storage.add(conn, zadd_command) + + # Get sorted set members (ascending by score) + zrange_command = RedisCommand( + method="zrange", + args=("leaderboard", 0, -1), + kwargs={"withscores": True} + ) + ascending = await storage.execute_command(conn, zrange_command) + + # Get sorted set members (descending by score) + zrevrange_command = RedisCommand( + method="zrevrange", + args=("leaderboard", 0, -1), + kwargs={"withscores": True} + ) + descending = await storage.execute_command(conn, zrevrange_command) + print(f"Leaderboard: {descending}") + + # Get members within a score range + zrangebyscore_command = RedisCommand( + method="zrangebyscore", + args=("leaderboard", 80, 100), + kwargs={"withscores": True} + ) + score_range = await storage.execute_command(conn, zrangebyscore_command) + + # Get member rank + zrank_command = RedisCommand(method="zrank", args=("leaderboard", "alice")) + rank = await storage.execute_command(conn, zrank_command) +``` + +### 6. Expiration Time Settings + +```python +# Several ways to set expiration time +async with storage.create_db_session() as conn: + # Method 1: Set expiration time in the command + command_with_expire = RedisCommand( + method="set", + args=("temp_key", "temp_value"), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) # Expires after 1 hour + ) + await storage.add(conn, command_with_expire) + + # Method 2: Use the EX parameter of the SET command + set_ex_command = RedisCommand( + method="set", + args=("temp_key2", "temp_value2"), + kwargs={"ex": 1800} # Expires after 30 minutes + ) + await storage.add(conn, set_ex_command) + + # Method 3: Set expiration time separately (expire is not in the methods supported by add, use execute_command instead) + expire_command = RedisCommand(method="expire", args=("existing_key", 7200)) + await storage.execute_command(conn, expire_command) + + # Check remaining expiration time + ttl_command = RedisCommand(method="ttl", args=("temp_key",)) + remaining_time = await storage.execute_command(conn, ttl_command) + print(f"Remaining TTL: {remaining_time} seconds") +``` + +## Complete Usage Example + +### Practical Application Example +```python +import asyncio +import json +from datetime import datetime, timedelta +from trpc_agent_sdk.storage import RedisStorage, RedisCommand, RedisExpire, RedisCondition +from trpc_agent_sdk.types import Ttl + +class CacheService: + """Cache service class example""" + + def __init__(self, redis_url: str): + self.storage = RedisStorage( + redis_url=redis_url, + is_async=True, + decode_responses=True, + max_connections=20, + socket_timeout=10, + retry_on_timeout=True + ) + + async def initialize(self): + """Initialize Redis connection""" + await self.storage.create_redis_engine() + + async def cache_user_session(self, user_id: int, session_data: dict, expire_hours: int = 24) -> bool: + """Cache user session""" + async with self.storage.create_db_session() as conn: + session_key = f"session:user:{user_id}" + command = RedisCommand( + method="set", + args=(session_key, json.dumps(session_data)), + expire=RedisExpire(ttl=Ttl(ttl_seconds=expire_hours * 3600)) + ) + await self.storage.add(conn, command) + return True + + async def get_user_session(self, user_id: int) -> dict: + """Get user session""" + async with self.storage.create_db_session() as conn: + session_key = f"session:user:{user_id}" + command = RedisCommand(method="get", args=(session_key,)) + result = await self.storage.get(conn, command) + + if result: + return json.loads(result) + return None + + async def cache_user_profile(self, user_id: int, profile: dict) -> bool: + """Cache user profile (using hash)""" + async with self.storage.create_db_session() as conn: + profile_key = f"profile:user:{user_id}" + + # Convert dictionary to hash fields + for field, value in profile.items(): + command = RedisCommand( + method="hset", + args=(profile_key, field, str(value)), + expire=RedisExpire(ttl=Ttl(ttl_seconds=7200)) # 2 hours + ) + await self.storage.add(conn, command) + + return True + + async def get_user_profile(self, user_id: int) -> dict: + """Get user profile""" + async with self.storage.create_db_session() as conn: + profile_key = f"profile:user:{user_id}" + command = RedisCommand(method="hgetall", args=(profile_key,)) + result = await self.storage.get(conn, command) + + if result: + # Convert back to appropriate data types + profile = {} + for key, value in result.items(): + if key in ['age', 'score']: + profile[key] = int(value) + elif key in ['rating']: + profile[key] = float(value) + else: + profile[key] = value + return profile + return {} + + async def add_user_activity(self, user_id: int, activity: str) -> bool: + """Add user activity (using list)""" + async with self.storage.create_db_session() as conn: + activity_key = f"activities:user:{user_id}" + timestamp = datetime.now().isoformat() + activity_with_time = f"{timestamp}: {activity}" + + command = RedisCommand( + method="lpush", + args=(activity_key, activity_with_time), + expire=RedisExpire(ttl=Ttl(ttl_seconds=86400)) # 1 day + ) + await self.storage.add(conn, command) + + # Keep list length within 100 + trim_command = RedisCommand(method="ltrim", args=(activity_key, 0, 99)) + await self.storage.execute_command(conn, trim_command) + + return True + + async def get_user_activities(self, user_id: int, limit: int = 10) -> list: + """Get user activities""" + async with self.storage.create_db_session() as conn: + activity_key = f"activities:user:{user_id}" + command = RedisCommand(method="lrange", args=(activity_key, 0, limit - 1)) + result = await self.storage.execute_command(conn, command) + + return result if result else [] + + async def add_to_leaderboard(self, user_id: int, score: float) -> bool: + """Add to leaderboard (using sorted set)""" + async with self.storage.create_db_session() as conn: + leaderboard_key = "leaderboard:global" + command = RedisCommand( + method="zadd", + args=(leaderboard_key, {f"user:{user_id}": score}), + expire=RedisExpire(ttl=Ttl(ttl_seconds=3600)) # 1 hour + ) + await self.storage.add(conn, command) + return True + + async def get_leaderboard(self, limit: int = 10) -> list: + """Get leaderboard""" + async with self.storage.create_db_session() as conn: + leaderboard_key = "leaderboard:global" + command = RedisCommand( + method="zrevrange", + args=(leaderboard_key, 0, limit - 1), + kwargs={"withscores": True} + ) + result = await self.storage.execute_command(conn, command) + + if result: + leaderboard = [ + {"user": member, "score": score} + for member, score in result + ] + return leaderboard + return [] + + async def cleanup_expired_sessions(self) -> int: + """Clean up expired sessions""" + async with self.storage.create_db_session() as conn: + condition = RedisCondition(limit=-1) + session_keys = await self.storage.query(conn, "session:*", condition) + + cleaned_count = 0 + for key, _value in session_keys: + # Check if the key still exists (may have expired or been deleted); use execute_command for EXISTS—not get (get only allows methods whose names contain "get") + exists = await self.storage.execute_command( + conn, RedisCommand(method="exists", args=(key,)) + ) + + if not exists: + cleaned_count += 1 + + return cleaned_count + + async def get_cache_stats(self) -> dict: + """Get cache statistics""" + async with self.storage.create_db_session() as conn: + info_command = RedisCommand(method="info", args=("memory",)) + memory_info = await self.storage.execute_command(conn, info_command) + + dbsize_command = RedisCommand(method="dbsize") + db_size = await self.storage.execute_command(conn, dbsize_command) + + return { + "total_keys": db_size, + "memory_info": memory_info, + "timestamp": datetime.now().isoformat() + } + + async def close(self): + """Close Redis connection""" + await self.storage.close() + +# Usage example +async def main(): + service = CacheService("redis://localhost:6379/0") + + try: + await service.initialize() + + # Cache user session + session_data = { + "user_id": 1, + "username": "alice", + "login_time": datetime.now().isoformat(), + "permissions": ["read", "write"] + } + await service.cache_user_session(1, session_data) + + # Get user session + session = await service.get_user_session(1) + print(f"User session: {session}") + + # Cache user profile + profile = { + "name": "Alice", + "age": 30, + "email": "alice@example.com", + "rating": 4.5 + } + await service.cache_user_profile(1, profile) + + # Get user profile + cached_profile = await service.get_user_profile(1) + print(f"User profile: {cached_profile}") + + # Add user activity + await service.add_user_activity(1, "Logged in") + await service.add_user_activity(1, "Updated profile") + + # Get user activities + activities = await service.get_user_activities(1) + print(f"User activities: {activities}") + + # Add to leaderboard + await service.add_to_leaderboard(1, 95.5) + await service.add_to_leaderboard(2, 87.2) + await service.add_to_leaderboard(3, 92.8) + + # Get leaderboard + leaderboard = await service.get_leaderboard() + print(f"Leaderboard: {leaderboard}") + + # Get cache statistics + stats = await service.get_cache_stats() + print(f"Cache stats: {stats}") + + finally: + await service.close() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Error Handling and Best Practices + +### 1. Exception Handling +```python +from redis.exceptions import ConnectionError, TimeoutError, RedisError + +async def safe_redis_operation(storage, key: str, value: str): + """Safe Redis operation example""" + async with storage.create_db_session() as conn: + try: + command = RedisCommand(method="set", args=(key, value)) + await storage.add(conn, command) + return True + + except ConnectionError as e: + print(f"Redis connection error: {e}") + return False + + except TimeoutError as e: + print(f"Redis operation timeout: {e}") + return False + + except RedisError as e: + print(f"Redis error: {e}") + return False + + except Exception as e: + print(f"Unknown error: {e}") + return False +``` + +### 2. Connection Management +```python +class RedisManager: + """Redis connection manager""" + + def __init__(self, redis_url: str): + self.storage = None + self.redis_url = redis_url + + async def __aenter__(self): + self.storage = RedisStorage( + redis_url=self.redis_url, + is_async=True, + max_connections=10, + socket_timeout=5, + retry_on_timeout=True + ) + await self.storage.create_redis_engine() + return self.storage + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.storage: + await self.storage.close() + +# Usage +async def example_with_manager(): + async with RedisManager("redis://localhost:6379/0") as storage: + async with storage.create_db_session() as conn: + # Execute Redis operations + pass +``` + +### 3. Performance Optimization Recommendations + +#### Connection Pool Configuration +```python +storage = RedisStorage( + redis_url=redis_url, + is_async=True, + max_connections=20, # Connection pool size + socket_timeout=10, # Socket timeout + socket_connect_timeout=5, # Connection timeout + retry_on_timeout=True, # Retry on timeout + health_check_interval=30, # Health check interval + decode_responses=True # Auto-decode responses +) +``` + +#### Batch Operations +```python +async def batch_set_keys(storage, key_value_pairs: dict): + """Batch set key-value pairs""" + async with storage.create_db_session() as conn: + try: + # Use MSET for batch setting (mset is not in the methods supported by add, use execute_command instead) + mset_command = RedisCommand(method="mset", args=(key_value_pairs,)) + await storage.execute_command(conn, mset_command) + + print(f"Successfully set {len(key_value_pairs)} keys") + + except Exception as e: + print(f"Batch operation failed: {e}") +``` + +#### Memory Optimization +```python +async def optimize_memory_usage(storage): + """Memory usage optimization""" + async with storage.create_db_session() as conn: + # Set appropriate expiration times + expire_policies = { + "session:*": 86400, # Session data: 1 day + "cache:*": 3600, # Cache data: 1 hour + "temp:*": 300 # Temporary data: 5 minutes + } + + for pattern, expire_time in expire_policies.items(): + condition = RedisCondition(limit=-1) + keys = await storage.query(conn, pattern, condition) + + for key, _value in keys: + expire_command = RedisCommand(method="expire", args=(key, expire_time)) + await storage.execute_command(conn, expire_command) +``` + +### 4. Monitoring and Debugging + +#### Enable Verbose Logging +```python +import logging + +# Configure logging +logging.basicConfig(level=logging.DEBUG) +redis_logger = logging.getLogger('redis') +redis_logger.setLevel(logging.DEBUG) + +# Create storage instance with logging +storage = RedisStorage( + redis_url=redis_url, + is_async=True, + decode_responses=True +) +``` + +#### Performance Monitoring +```python +async def monitor_redis_performance(storage): + """Monitor Redis performance""" + async with storage.create_db_session() as conn: + # Get Redis info + info_command = RedisCommand(method="info") + info = await storage.execute_command(conn, info_command) + + # Parse key metrics + if info: + lines = info.split('\n') + metrics = {} + for line in lines: + if ':' in line and not line.startswith('#'): + key, value = line.strip().split(':', 1) + metrics[key] = value + + # Output key metrics + print(f"Used Memory: {metrics.get('used_memory_human', 'N/A')}") + print(f"Connected Clients: {metrics.get('connected_clients', 'N/A')}") + print(f"Total Commands: {metrics.get('total_commands_processed', 'N/A')}") + print(f"Keyspace Hits: {metrics.get('keyspace_hits', 'N/A')}") + print(f"Keyspace Misses: {metrics.get('keyspace_misses', 'N/A')}") +``` + +## Troubleshooting + +### Common Errors and Solutions + +1. **Connection Error** + ```python + # Error: ConnectionError: Error connecting to Redis + # Solution: Check Redis service status and connection parameters + + # Test connection + try: + await storage.create_redis_engine() + print("Redis connection successful") + except Exception as e: + print(f"Connection failed: {e}") + ``` + +2. **Authentication Error** + ```python + # Error: AuthenticationError: Authentication required + # Solution: Check password configuration + + # Correct password format + redis_url = "redis://:your_password@localhost:6379/0" + ``` + +3. **Timeout Error** + ```python + # Error: TimeoutError: Timeout reading from socket + # Solution: Adjust timeout settings + + storage = RedisStorage( + redis_url=redis_url, + socket_timeout=30, # Increase timeout + socket_connect_timeout=10, # Increase connection timeout + retry_on_timeout=True # Enable timeout retry + ) + ``` + +4. **Out of Memory Error** + ```python + # Error: OOM command not allowed when used memory > 'maxmemory' + # Solution: Clean up expired data or increase memory + + async def cleanup_expired_data(storage): + async with storage.create_db_session() as conn: + # Add TTL to keys that have none (not "delete expired keys"—expired keys are usually already removed by Redis) + condition = RedisCondition(limit=-1) + all_keys = await storage.query(conn, "*", condition) + + updated_count = 0 + for key, _value in all_keys: + ttl_command = RedisCommand(method="ttl", args=(key,)) + ttl = await storage.execute_command(conn, ttl_command) + + if ttl == -1: # Key exists but has no expiration + expire_command = RedisCommand(method="expire", args=(key, 3600)) + await storage.execute_command(conn, expire_command) + updated_count += 1 + + print(f"Set expiration on {updated_count} keys that had no TTL") + ``` + +### Performance Issue Diagnosis + +#### Connection Pool Monitoring +```python +async def diagnose_connection_pool(storage): + """Diagnose connection pool status""" + async with storage.create_db_session() as conn: + info_command = RedisCommand(method="info", args=("clients",)) + client_info = await storage.execute_command(conn, info_command) + + if client_info: + print("Client Information:") + for line in client_info.split('\n'): + if line.startswith('connected_clients'): + print(f" {line}") + elif line.startswith('client_recent_max_input_buffer'): + print(f" {line}") + elif line.startswith('client_recent_max_output_buffer'): + print(f" {line}") +``` + +#### Memory Usage Analysis +```python +async def analyze_memory_usage(storage): + """Analyze memory usage""" + async with storage.create_db_session() as conn: + info_command = RedisCommand(method="info", args=("memory",)) + memory_info = await storage.execute_command(conn, info_command) + + if memory_info: + print("Memory Usage Analysis:") + important_metrics = [ + 'used_memory_human', + 'used_memory_peak_human', + 'used_memory_rss_human', + 'mem_fragmentation_ratio', + 'maxmemory_human' + ] + + for line in memory_info.split('\n'): + for metric in important_metrics: + if line.startswith(metric): + print(f" {line}") +``` + +### Debug Mode and Logging + +#### Verbose Logging Configuration +```python +import logging + +# Configure Redis-related logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +# Set specific log levels +redis_logger = logging.getLogger('redis') +redis_logger.setLevel(logging.INFO) # Show important info only + +# Set connection logging +connection_logger = logging.getLogger('redis.connection') +connection_logger.setLevel(logging.DEBUG) # Show connection details +``` + +#### Command Execution Tracing +```python +import time + +class DebugRedisStorage(RedisStorage): + """Redis storage with debugging capabilities""" + + async def add(self, conn, command): + print(f"Executing command: {command.method} with args: {command.args}") + start_time = time.time() + + try: + result = await super().add(conn, command) + execution_time = time.time() - start_time + print(f"Command completed in {execution_time:.3f}s") + return result + except Exception as e: + execution_time = time.time() - start_time + print(f"Command failed after {execution_time:.3f}s: {e}") + raise +``` + +## Installation and Environment Configuration + +### Basic Run +```bash +# 1. Ensure Redis service is running +redis-server + +# 2. Install dependencies +pip install redis +``` + +### Run with Environment Configuration +```bash +# Set environment variables +export REDIS_HOST=localhost +export REDIS_PORT=6379 +export REDIS_DB=0 +export REDIS_PASSWORD=your_password +export REDIS_MAX_CONNECTIONS=20 +``` + +### Run in Docker Environment +```bash +# Start Redis container +docker run -d --name redis-test -p 6379:6379 redis:latest + +# Clean up container +docker stop redis-test && docker rm redis-test +``` \ No newline at end of file diff --git a/docs/mkdocs/en/session_sql.md b/docs/mkdocs/en/session_sql.md new file mode 100644 index 000000000..6b32e3ee4 --- /dev/null +++ b/docs/mkdocs/en/session_sql.md @@ -0,0 +1,847 @@ +# SqlStorage Database Storage Usage Guide + +This document is a detailed guide to using the `SqlStorage` class for database operations, including support for MySQL, PostgreSQL, SQLite, and other databases. + +## Overview + +`SqlStorage` is an async/sync database storage implementation based on SQLAlchemy, providing a unified interface for handling various SQL database operations. + +## Core Components + +### 1. SqlStorage Class +The primary storage class that provides database connection and operation interfaces. + +### 2. Helper Classes +- `SqlKey`: Specifies keys for database lookups and queries (for example, primary keys for `get`) +- `SqlCondition`: Used to define query conditions +- `StorageData`: Base class for data models + +## Prerequisites + +### 1. Install Required Dependencies + +```bash +# Core dependencies +pip install sqlalchemy + +# MySQL support +pip install aiomysql PyMySQL + +# PostgreSQL support +pip install asyncpg psycopg2 + +# SQLite support (built into Python) +# No additional installation required +``` + +### 2. Database Setup + +#### MySQL Setup +```sql +-- Create database +CREATE DATABASE test_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- Create user (optional) +CREATE USER 'test_user'@'localhost' IDENTIFIED BY 'test_password'; +GRANT ALL PRIVILEGES ON test_db.* TO 'test_user'@'localhost'; +FLUSH PRIVILEGES; +``` + +#### PostgreSQL Setup +```sql +-- Create database +CREATE DATABASE test_db; + +-- Create user (optional) +CREATE USER test_user WITH PASSWORD 'test_password'; +GRANT ALL PRIVILEGES ON DATABASE test_db TO test_user; +``` + +--- + +## Basic Usage + +### 1. Initialize SqlStorage + +```python +from trpc_agent_sdk.storage import SqlStorage + +# Async mode (recommended) +storage = SqlStorage( + is_async=True, + db_url="mysql+aiomysql://root:password@localhost/test_db", + echo=True, # Enable SQL logging + pool_size=10, + max_overflow=20 +) + +# Sync mode +storage = SqlStorage( + is_async=False, + db_url="mysql+pymysql://root:password@localhost/test_db", + echo=True +) +``` + +### 2. Define Data Models + +```python +from dataclasses import dataclass +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, Text +from trpc_agent_sdk.storage import StorageData + +@dataclass +class UserData(StorageData): + """User data model""" + __tablename__ = 'users' + + id: int = Column(Integer, primary_key=True, autoincrement=True) + username: str = Column(String(50), unique=True, nullable=False) + email: str = Column(String(100), nullable=False) + full_name: str = Column(String(100), nullable=True) + bio: str = Column(Text, nullable=True) + created_at: datetime = Column(DateTime, default=datetime.utcnow) + updated_at: datetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +``` + +### 3. Basic Operation Example + +```python +import asyncio +from trpc_agent_sdk.storage import SqlStorage, SqlKey, SqlCondition + +async def basic_example(): + # Initialize storage + storage = SqlStorage( + is_async=True, + db_url="mysql+aiomysql://root:password@localhost/test_db" + ) + + try: + # Create database engine and tables + await storage.create_sql_engine() + + # Use database session + async with storage.create_db_session() as session: + # Create new user + user = UserData( + username="john_doe", + email="john@example.com", + full_name="John Doe", + bio="Software engineer" + ) + + # Add user + await storage.add(session, user) + await storage.commit(session) + await storage.refresh(session, user) + + print(f"Created user with ID: {user.id}") + + # Get user + user_key = SqlKey(key=(user.id,), storage_cls=UserData) + retrieved_user = await storage.get(session, user_key) + print(f"Retrieved user: {retrieved_user.username}") + + # Query users + query_key = SqlKey(key=(), storage_cls=UserData) + condition = SqlCondition( + filters=[UserData.email.like('%@example.com')], + order_func=lambda: UserData.created_at.desc(), + limit=10 + ) + users = await storage.query(session, query_key, condition) + print(f"Found {len(users)} users") + + finally: + await storage.close() + +# Run example +asyncio.run(basic_example()) +``` + +### 4. Configuration Management + +```python +import os +from dataclasses import dataclass +from typing import Dict, Any + +@dataclass +class DatabaseConfig: + """Database configuration class""" + host: str = "localhost" + port: int = 3306 + username: str = "root" + password: str = "password" + database: str = "test_db" + charset: str = "utf8mb4" + + # Connection pool settings + pool_size: int = 10 + max_overflow: int = 20 + pool_timeout: int = 30 + pool_recycle: int = 3600 + + # SQLAlchemy settings + echo: bool = False + echo_pool: bool = False + + def get_async_url(self) -> str: + """Get async connection URL""" + return f"mysql+aiomysql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}?charset={self.charset}" + + def get_sync_url(self) -> str: + """Get sync connection URL""" + return f"mysql+pymysql://{self.username}:{self.password}@{self.host}:{self.port}/{self.database}?charset={self.charset}" + + def get_engine_kwargs(self) -> Dict[str, Any]: + """Get engine parameters""" + return { + "echo": self.echo, + "echo_pool": self.echo_pool, + "pool_size": self.pool_size, + "max_overflow": self.max_overflow, + "pool_timeout": self.pool_timeout, + "pool_recycle": self.pool_recycle, + } + + @classmethod + def from_env(cls) -> 'DatabaseConfig': + """Create configuration from environment variables""" + return cls( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "3306")), + username=os.getenv("DB_USER", "root"), + password=os.getenv("DB_PASSWORD", "password"), + database=os.getenv("DB_NAME", "test_db"), + echo=os.getenv("DB_ECHO", "false").lower() == "true", + ) +``` + +## SqlStorage Interface Details + +### 1. Core Interfaces + +#### Database Engine Management +```python +# Create database engine and tables +await storage.create_sql_engine() + +# Close database connection +await storage.close() +``` + +#### Session Management +```python +# Create database session (recommended to use context manager) +async with storage.create_db_session() as session: + # Execute database operations here + pass + +# Create raw session (requires manual management) +session = await storage.create_sql_session() +``` + +### 2. CRUD Operations + +#### Add Data +```python +async with storage.create_db_session() as session: + user = UserData(username="test", email="test@example.com") + await storage.add(session, user) + await storage.commit(session) + await storage.refresh(session, user) # Get auto-generated ID +``` + +#### Get Data +```python +async with storage.create_db_session() as session: + # Get by primary key + user_key = SqlKey(key=(user_id,), storage_cls=UserData) + user = await storage.get(session, user_key) +``` + +#### Query Data +```python +async with storage.create_db_session() as session: + query_key = SqlKey(key=(), storage_cls=UserData) + + # Simple query + condition = SqlCondition() + all_users = await storage.query(session, query_key, condition) + + # Query with conditions + condition = SqlCondition( + filters=[ + UserData.email.like('%@example.com'), + UserData.created_at > datetime(2024, 1, 1) + ], + order_func=lambda: UserData.created_at.desc(), + limit=10 + ) + filtered_users = await storage.query(session, query_key, condition) +``` + +#### Delete Data +```python +async with storage.create_db_session() as session: + delete_key = SqlKey(key=(), storage_cls=UserData) + condition = SqlCondition(filters=[UserData.id == user_id]) + await storage.delete(session, delete_key, condition) + await storage.commit(session) +``` + +#### Update Data +```python +async with storage.create_db_session() as session: + user_key = SqlKey(key=(user_id,), storage_cls=UserData) + user = await storage.get(session, user_key) + + if user: + user.bio = "Updated bio" + user.updated_at = datetime.utcnow() + await storage.commit(session) + await storage.refresh(session, user) +``` + +### 3. Advanced Features + +#### Transaction Management +```python +async with storage.create_db_session() as session: + try: + # Execute multiple operations + await storage.add(session, user1) + await storage.add(session, user2) + await storage.commit(session) + except Exception as e: + # Auto-rollback (handled by context manager) + print(f"Transaction failed: {e}") + raise +``` + +#### Batch Operations +```python +async with storage.create_db_session() as session: + users = [ + UserData(username=f"user_{i}", email=f"user_{i}@example.com") + for i in range(10) + ] + + for user in users: + await storage.add(session, user) + + await storage.commit(session) +``` + +#### Complex Query Conditions +```python +from sqlalchemy import and_, or_ + +condition = SqlCondition( + filters=[ + and_( + UserData.created_at > datetime(2024, 1, 1), + or_( + UserData.email.like('%@gmail.com'), + UserData.email.like('%@yahoo.com') + ) + ) + ], + order_func=lambda: [UserData.created_at.desc(), UserData.username.asc()], + limit=50 +) +``` + +## Database Connection URLs + +### MySQL +```python +# Async connection (recommended) +mysql_async_url = "mysql+aiomysql://username:password@host:port/database" + +# Sync connection +mysql_sync_url = "mysql+pymysql://username:password@host:port/database" + +# Connection with parameters +mysql_url = "mysql+aiomysql://user:pass@localhost/db?charset=utf8mb4&autocommit=true" + +# SSL connection +mysql_ssl_url = "mysql+pymysql://user:pass@host/db?ssl_ca=/path/to/ca.pem" +``` + +### PostgreSQL +```python +# Async connection +postgres_async_url = "postgresql+asyncpg://username:password@host:port/database" + +# Sync connection +postgres_sync_url = "postgresql+psycopg2://username:password@host:port/database" + +# Connection with parameters +postgres_url = "postgresql+asyncpg://user:pass@localhost/db?ssl=require" +``` + +### SQLite +```python +# Async connection +sqlite_async_url = "sqlite+aiosqlite:///path/to/database.db" + +# Sync connection +sqlite_sync_url = "sqlite:///path/to/database.db" + +# In-memory database +sqlite_memory_url = "sqlite:///:memory:" +``` + +## Data Model Definitions + +### Basic Model +```python +from dataclasses import dataclass +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, ForeignKey +from sqlalchemy.orm import relationship +from trpc_agent_sdk.storage import StorageData + +@dataclass +class UserData(StorageData): + """User data model""" + __tablename__ = 'users' + + id: int = Column(Integer, primary_key=True, autoincrement=True) + username: str = Column(String(50), unique=True, nullable=False, index=True) + email: str = Column(String(100), nullable=False, index=True) + full_name: str = Column(String(100), nullable=True) + bio: str = Column(Text, nullable=True) + is_active: bool = Column(Boolean, default=True) + created_at: datetime = Column(DateTime, default=datetime.utcnow, index=True) + updated_at: datetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +``` + +### Related Models +```python +@dataclass +class PostData(StorageData): + """Post data model""" + __tablename__ = 'posts' + + id: int = Column(Integer, primary_key=True, autoincrement=True) + title: str = Column(String(200), nullable=False) + content: str = Column(Text, nullable=False) + user_id: int = Column(Integer, ForeignKey('users.id'), nullable=False) + created_at: datetime = Column(DateTime, default=datetime.utcnow) + + # Relationship (optional) + # user = relationship("UserData", back_populates="posts") + +@dataclass +class TagData(StorageData): + """Tag data model""" + __tablename__ = 'tags' + + id: int = Column(Integer, primary_key=True, autoincrement=True) + name: str = Column(String(50), unique=True, nullable=False) + description: str = Column(String(200), nullable=True) + created_at: datetime = Column(DateTime, default=datetime.utcnow) +``` + +### Model Best Practices +```python +@dataclass +class BaseModel(StorageData): + """Base model class""" + __abstract__ = True # Abstract base class, will not create a table + + id: int = Column(Integer, primary_key=True, autoincrement=True) + created_at: datetime = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: datetime = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + +@dataclass +class ProductData(BaseModel): + """Product data model""" + __tablename__ = 'products' + + name: str = Column(String(100), nullable=False, index=True) + price: int = Column(Integer, nullable=False) # Stored in cents + description: str = Column(Text, nullable=True) + is_available: bool = Column(Boolean, default=True, index=True) + category_id: int = Column(Integer, ForeignKey('categories.id'), nullable=True) +``` + +## Complete Usage Example + +### Practical Application Example +```python +import asyncio +from datetime import datetime +from trpc_agent_sdk.storage import SqlStorage, SqlKey, SqlCondition + +class UserService: + """User service class example""" + + def __init__(self, db_url: str): + self.storage = SqlStorage( + is_async=True, + db_url=db_url, + echo=True, + pool_size=10, + max_overflow=20 + ) + + async def initialize(self): + """Initialize database""" + await self.storage.create_sql_engine() + + async def create_user(self, username: str, email: str, full_name: str = None) -> int: + """Create user""" + async with self.storage.create_db_session() as session: + user = UserData( + username=username, + email=email, + full_name=full_name, + created_at=datetime.utcnow() + ) + + await self.storage.add(session, user) + await self.storage.commit(session) + await self.storage.refresh(session, user) + + return user.id + + async def get_user_by_id(self, user_id: int) -> UserData: + """Get user by ID""" + async with self.storage.create_db_session() as session: + user_key = SqlKey(key=(user_id,), storage_cls=UserData) + return await self.storage.get(session, user_key) + + async def find_users_by_email_domain(self, domain: str, limit: int = 10) -> list: + """Find users by email domain""" + async with self.storage.create_db_session() as session: + query_key = SqlKey(key=(), storage_cls=UserData) + condition = SqlCondition( + filters=[UserData.email.like(f'%@{domain}')], + order_func=lambda: UserData.created_at.desc(), + limit=limit + ) + return await self.storage.query(session, query_key, condition) + + async def update_user_bio(self, user_id: int, bio: str) -> bool: + """Update user bio""" + async with self.storage.create_db_session() as session: + user_key = SqlKey(key=(user_id,), storage_cls=UserData) + user = await self.storage.get(session, user_key) + + if user: + user.bio = bio + user.updated_at = datetime.utcnow() + await self.storage.commit(session) + return True + return False + + async def delete_inactive_users(self, days: int = 30) -> int: + """Delete inactive users""" + from datetime import timedelta + cutoff_date = datetime.utcnow() - timedelta(days=days) + + async with self.storage.create_db_session() as session: + delete_key = SqlKey(key=(), storage_cls=UserData) + condition = SqlCondition( + filters=[ + UserData.is_active == False, + UserData.updated_at < cutoff_date + ] + ) + + # Query users to be deleted first + users_to_delete = await self.storage.query(session, delete_key, condition) + count = len(users_to_delete) + + # Execute deletion + await self.storage.delete(session, delete_key, condition) + await self.storage.commit(session) + + return count + + async def close(self): + """Close database connection""" + await self.storage.close() + +# Usage example +async def main(): + service = UserService("mysql+aiomysql://root:password@localhost/test_db") + + try: + await service.initialize() + + # Create user + user_id = await service.create_user("john_doe", "john@example.com", "John Doe") + print(f"Created user with ID: {user_id}") + + # Get user + user = await service.get_user_by_id(user_id) + print(f"Retrieved user: {user.username}") + + # Find users + users = await service.find_users_by_email_domain("example.com") + print(f"Found {len(users)} users with example.com email") + + # Update user + success = await service.update_user_bio(user_id, "Updated bio") + print(f"Update successful: {success}") + + finally: + await service.close() + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Error Handling and Best Practices + +### 1. Exception Handling +```python +from sqlalchemy.exc import IntegrityError, SQLAlchemyError + +async def safe_create_user(storage, username: str, email: str): + """Safe user creation example""" + async with storage.create_db_session() as session: + try: + user = UserData(username=username, email=email) + await storage.add(session, user) + await storage.commit(session) + await storage.refresh(session, user) + return user.id + + except IntegrityError as e: + print(f"Data integrity error (possibly duplicate data): {e}") + return None + + except SQLAlchemyError as e: + print(f"Database error: {e}") + return None + + except Exception as e: + print(f"Unknown error: {e}") + return None +``` + +### 2. Connection Management +```python +class DatabaseManager: + """Database manager""" + + def __init__(self, db_url: str): + self.storage = None + self.db_url = db_url + + async def __aenter__(self): + self.storage = SqlStorage(is_async=True, db_url=self.db_url) + await self.storage.create_sql_engine() + return self.storage + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.storage: + await self.storage.close() + +# Usage +async def example_with_manager(): + async with DatabaseManager("mysql+aiomysql://...") as storage: + async with storage.create_db_session() as session: + # Execute database operations + pass +``` + +### 3. Performance Optimization Recommendations + +#### Connection Pool Configuration +```python +storage = SqlStorage( + is_async=True, + db_url=db_url, + pool_size=20, # Connection pool size + max_overflow=30, # Maximum overflow connections + pool_timeout=30, # Connection acquisition timeout + pool_recycle=3600, # Connection recycle time (seconds) + pool_pre_ping=True, # Ping connections before use from the pool +) +``` + +#### Batch Operations +```python +async def batch_create_users(storage, users_data: list): + """Batch create users""" + async with storage.create_db_session() as session: + try: + for user_data in users_data: + user = UserData(**user_data) + await storage.add(session, user) + + await storage.commit(session) + print(f"Successfully created {len(users_data)} users") + + except Exception as e: + print(f"Batch operation failed: {e}") + # Session will auto-rollback +``` + +#### Query Optimization +```python +# Use indexed fields for queries +condition = SqlCondition( + filters=[ + UserData.username == "john_doe", # username has index + UserData.is_active == True # is_active has index + ] +) + +# Limit query result count +condition = SqlCondition( + filters=[UserData.created_at > datetime(2024, 1, 1)], + order_func=lambda: UserData.created_at.desc(), + limit=100 # Limit result count +) +``` + +## Troubleshooting + +### Common Errors and Solutions + +1. **Connection Error** + ```python + # Error: Can't connect to MySQL server + # Solution: Check database service status and connection parameters + + # Test connection + try: + await storage.create_sql_engine() + print("Database connection successful") + except Exception as e: + print(f"Connection failed: {e}") + ``` + +2. **Table Not Found Error** + ```python + # Error: Table 'database.table_name' doesn't exist + # Solution: Ensure create_sql_engine() has been called + + await storage.create_sql_engine() # This creates all tables + ``` + +3. **Data Integrity Error** + ```python + # Error: Duplicate entry 'value' for key 'column_name' + # Solution: Check unique constraint fields + + try: + await storage.add(session, user) + await storage.commit(session) + except IntegrityError: + print("Record already exists or violates a constraint") + ``` + +### Debug Mode +```python +# Enable verbose logging +import logging +logging.basicConfig(level=logging.DEBUG) + +storage = SqlStorage( + is_async=True, + db_url=db_url, + echo=True, # Display SQL statements + echo_pool=True, # Display connection pool info +) +``` + +## Environment Setup + +```bash +# Install dependencies +pip install sqlalchemy aiomysql + +# Set environment variables +export DB_URL="mysql+aiomysql://root:password@localhost/test_db" + +# Use a different database +export DB_URL="postgresql+asyncpg://user:pass@localhost/test_db" +``` + +## Supported Databases + +### PostgreSQL +- psycopg2 + ```txt + Required package: pip install psycopg2-binary + url: "postgresql+psycopg2://username:password@localhost:5432/mydb" + ``` +- asyncpg + ```txt + Required package: pip install asyncpg + url: "postgresql+asyncpg://username:password@localhost:5432/mydb" + ``` +- pg8000 + ```txt + Required package: pip install pg8000 + url: "postgresql+pg8000://username:password@localhost:5432/mydb" + ``` + +### MySQL/MariaDB +- PyMySQL + ```txt + Required package: pip install PyMySQL + url: "mysql+pymysql://username:password@localhost:3306/mydb" + ``` +- mysqlclient + ```txt + Required package: pip install mysqlclient + url: "mysql+mysqldb://username:password@localhost:3306/mydb" + ``` +- mysqlconnector + ```txt + Required package: pip install mysql-connector-python + url: "mysql+mysqlconnector://username:password@localhost:3306/mydb" + ``` +- aiomysql + ```txt + Required package: pip install aiomysql + url: "mysql+aiomysql://username:password@localhost:3306/mydb" + ``` + +### SQLite +- sqlite3 + ```txt + # Built-in, no installation required + url: "sqlite:///./test.db" + ``` +- aiosqlite + ```txt + Required package: pip install aiosqlite + url: "sqlite+aiosqlite:///./test.db" + ``` + +### Oracle +- cx_Oracle + ```txt + Required package: pip install cx_Oracle + url: "oracle+cx_oracle://username:password@localhost:1521/xe" + ``` +- oracledb + ```txt + Required package: pip install oracledb + url: "oracle+oracledb://username:password@localhost:1521/xe" + ``` + +### SQL Server +- pyodbc + ```txt + Required package: pip install pyodbc + url: "mssql+pyodbc://username:password@server:1433/database?driver=ODBC+Driver+17+for+SQL+Server" + ``` +- pymssql + ```txt + Required package: pip install pymssql + url: "mssql+pymssql://username:password@server:1433/database" + ``` diff --git a/docs/mkdocs/en/session_summary.md b/docs/mkdocs/en/session_summary.md new file mode 100644 index 000000000..a3246efe6 --- /dev/null +++ b/docs/mkdocs/en/session_summary.md @@ -0,0 +1,579 @@ +# Session Summarizer + +As the number of conversation turns increases, events accumulated in a Session continue to grow, leading to excessively long context and increased token consumption. Session Summarizer intelligently compresses historical conversations into summaries, effectively controlling session size while preserving key context. It is an essential core component in TRPC Agent for long-conversation scenarios. + +## Overview + +Session Summarizer intelligently analyzes conversation history and summarizes older conversation events into concise summaries, thereby: + +- **Session Compression**: Compresses long conversation history into concise summaries +- **Reduced Token Usage**: Reduces token consumption and saves costs +- **Preserves Important Context**: Retains key information and decisions +- **Improved Performance**: Reduces the number of events to process + +## Core Components + +### SessionSummarizer Class + +The main summarizer class responsible for the core logic of session compression. + +### SessionSummary Class + +A data structure representing a session summary, containing summary information and metadata. + +### SummarizerSessionManager Class + +The session summary manager responsible for automatically triggering and managing summarization at the SessionService level. + +--- + +## Basic Usage + +### 1. Creating a SessionSummarizer + +```python +from trpc_agent_sdk.sessions import SessionSummarizer, set_summarizer_conversation_threshold +from trpc_agent_sdk.models import OpenAIModel + +# Create an LLM model +model = OpenAIModel( + model_name="deepseek-chat", + api_key="your-api-key", + base_url="https://api.deepseek.com/v1" +) + +# Summarize after every summarizer_count conversation turns +# If summarizer_count is set to 3, summarization is performed after every 3 turns +summarizer_count = 3 + +# Create the summarizer +summarizer = SessionSummarizer( + model=model, + # If check_summarizer_functions is not set, the default is set_summarizer_conversation_threshold(100) + # Summarization is triggered when the check functions in check_summarizer_functions return True + # When multiple check functions exist, AND logic is used by default (summarization occurs only when all functions return True) + check_summarizer_functions=[ + set_summarizer_conversation_threshold(summarizer_count), # Conversation count check function, summarizes after every summarizer_count turns + # set_summarizer_time_interval_threshold(10), # Time check function, summarizes every 10 seconds + # set_summarizer_token_threshold(1000), # Token check function, summarizes every 1000 tokens + # set_summarizer_events_count_threshold(30), # Event count check function, summarizes every 30 events + # set_summarizer_important_content_threshold(), # Important content check function, determines whether to summarize based on content importance + # set_summarizer_check_functions_by_and( # Combined check function with AND logic, triggers summarization when all check functions return True + # set_summarizer_conversation_threshold(1), + # set_summarizer_time_interval_threshold(10), + # set_summarizer_token_threshold(1000), + # set_summarizer_important_content_threshold(), + # ), + # set_summarizer_check_functions_by_or( # Combined check function with OR logic, triggers summarization when any check function returns True + # set_summarizer_conversation_threshold(1), + # set_summarizer_time_interval_threshold(10), + # ) + ], + max_summary_length=600, # Maximum length of the summary text, default is 1000, truncated with ... if exceeded + keep_recent_count=4, # Number of recent conversation turns to keep, default is 10 +) +``` + +--- + +### 2. Automatic Summarization (SessionService Level) + +Use `SummarizerSessionManager` with `SessionService` to enable automatic summarization in the Runner. + +**Complete Example**: Refer to [`examples/session_summarizer/run_agent.py`](../../../examples/session_summarizer/run_agent.py) + +```python +from trpc_agent_sdk.sessions import SummarizerSessionManager, InMemorySessionService +from trpc_agent_sdk.runners import Runner + +# Create SummarizerSessionManager +summarizer_manager = SummarizerSessionManager( + model=model, + summarizer=summarizer, + auto_summarize=True, # Default is True; if set to False, automatic summarization is disabled +) + +# Use with SessionService +session_service = InMemorySessionService(summarizer_manager=summarizer_manager) + +# Create Runner +runner = Runner( + app_name=app_name, + agent=agent, + session_service=session_service +) + +# Run the Agent (summarization is triggered automatically) +for i, user_input in enumerate(conversations): + await run_agent(runner=runner, user_id=user_id, session_id=session_id, user_input=user_input) + + # Summarization should be triggered after every summarizer_count turns + if i % summarizer_count == 0: + session = await session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id + ) + if session: + # Get the summary content + summary = await session_service.summarizer_manager.get_session_summary(session) + if summary: + print(f" - Summary text: {summary.summary_text[:100]}...") + print(f" - Original event count: {summary.original_event_count}") + print(f" - Compressed event count: {summary.compressed_event_count}") + print(f" - Compression ratio: {summary.get_compression_ratio():.1f}%") +``` + +**Workflow**: + +1. **Automatic Triggering**: After every N conversation turns, `SummarizerSessionManager` automatically checks whether summarization is needed +2. **Summary Generation**: Uses the LLM to compress historical conversations into concise summaries +3. **Event Compression**: Retains the most recent N conversation turns and replaces older conversations with summary text +4. **Session Update**: Updates the event list in the Session + +**Summary Content Usage**: + +After each summarization, the content `summary.summary_text` is injected into the corresponding request prompt in subsequent conversations. This process is transparent to the user. + +--- + +### 3. Manual Session Summarization + +**Complete Example**: Refer to [`examples/session_summarizer/run_agent.py`](../../../examples/session_summarizer/run_agent.py) + +```python +import time + +# Build events in the session +session = await create_test_session_with_events(session_service, app_name, user_id, session_id) + +# Force manual summarization (force=True bypasses trigger conditions) +await session_service.summarizer_manager.create_session_summary(session, force=True) + +if session: + summary = await session_service.summarizer_manager.get_session_summary(session) + if summary: + print(f" - Summary text: {summary.summary_text[:100]}...") + print(f" - Summary time: {time.ctime(summary.summary_timestamp)}") + print(f" - Original event count: {summary.original_event_count}") + print(f" - Compressed event count: {summary.compressed_event_count}") + print(f" - Compression ratio: {summary.get_compression_ratio():.1f}%") +``` + +--- + +## Configuration Parameters + +### SessionSummarizer Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | LLMModel | Required | LLM model used for generating summaries | +| `check_summarizer_functions` | List[CheckSummarizerFunction] | `[set_summarizer_conversation_threshold(100)]` | List of check functions that trigger summarization. When multiple check functions exist, AND logic is used by default, meaning summarization occurs only when all functions return True | +| `max_summary_length` | int | 1000 | Maximum length of the generated summary | +| `keep_recent_count` | int | 10 | Number of recent events to keep after compression (counted by turns; each turn typically contains 2 events: a user message and an assistant response) | +| `summarizer_prompt` | str | DEFAULT_SUMMARIZER_PROMPT | Custom summary prompt template | + +### SummarizerSessionManager Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | LLMModel | Required | LLM model used for generating summaries | +| `summarizer` | SessionSummarizer | None | Summarizer instance; if not provided, one is created with default configuration | +| `auto_summarize` | bool | True | Whether to enable automatic summarization; if set to False, automatic summarization is disabled | + +### Configuration Recommendations + +#### High-Frequency Conversation Scenario + +```python +summarizer = SessionSummarizer( + model=model, + check_summarizer_functions=[set_summarizer_conversation_threshold(20)], # More frequent summarization + keep_recent_count=5, # Keep fewer events +) +``` + +#### Long Conversation Scenario + +```python +summarizer = SessionSummarizer( + model=model, + check_summarizer_functions=[set_summarizer_conversation_threshold(50)], # Summarize after more conversation turns + keep_recent_count=15, # Keep more context + max_summary_length=1500, # Longer summary +) +``` + +#### Memory-Sensitive Scenario + +```python +summarizer = SessionSummarizer( + model=model, + check_summarizer_functions=[set_summarizer_events_count_threshold(15)], # Quick summarization + keep_recent_count=3, # Minimum retention +) +``` + +--- + +## Advanced Features + +### 1. Skip Summarization Control + +Certain events can be marked to skip summarization: + +```python +from trpc_agent_sdk.types import EventActions + +# Create an event that skips summarization +event = Event( + invocation_id="inv_123", + author="system", + content=Content(parts=[Part.from_text("Debug information")]), + actions=EventActions(skip_summarization=True) # Skip summarization +) +``` + +### 2. Retrieving Summary Metadata + +```python +# Get summarizer configuration info +metadata = summarizer.get_summary_metadata() +print(f"Model name: {metadata['model_name']}") +print(f"Retained event count: {metadata['keep_recent_count']}") +``` + +### 3. Using the SessionSummary Object + +```python +from trpc_agent_sdk.sessions import SessionSummary + +# Get the summary object +summary = await session_service.summarizer_manager.get_session_summary(session) + +# Get the compression ratio +compression_ratio = summary.get_compression_ratio() +print(f"Compression ratio: {compression_ratio:.1f}%") + +# Convert to dictionary +summary_dict = summary.to_dict() +``` + +### 4. Agent-Level Summarization (Filter Approach) + +In multi-Agent scenarios, summarization covers data produced by all Agents. However, different Agents may produce different volumes of data, and the business logic may require summarizing only data produced by a specific Agent. + +**Complete Implementation**: Refer to [`examples/session_summarizer/agent/filters.py`](../../../examples/session_summarizer/agent/filters.py) + +**Usage**: + +```python +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.sessions import SessionSummarizer +from trpc_agent_sdk.context import get_invocation_ctx + +class AgentSessionSummarizerFilter(BaseFilter): + """Agent session summarizer filter.""" + + def __init__(self, model: OpenAIModel): + super().__init__() + # Create the summarizer + self.summarizer = SessionSummarizer( + model=model, + max_summary_length=600, + keep_recent_count=4, # Number of recent conversation turns to keep, default is 10 + ) + + async def _after_every_stream(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + """Check whether summarization is needed after each streaming response""" + # The current agent stream returns one event per response; rsp is of type FilterResult, where rsp.rsp is of type Event + if not rsp.rsp.partial: + events = ctx.metadata.get("events", []) + conversation_text = self.summarizer._extract_conversation_text(events) + # Trigger summarization when conversation text exceeds 12KB + if len(conversation_text) > 12 * 1024: + await self._do_summarize(ctx) + + # Cache the executed events in the context + if "events" not in ctx.metadata: + ctx.metadata["events"] = [] + ctx.metadata["events"].append(rsp.rsp) + + async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Post-processing after the entire agent execution completes""" + await self._do_summarize(ctx) + + async def _do_summarize(self, ctx: AgentContext): + """Perform summarization""" + invocation_ctx: InvocationContext = get_invocation_ctx() + + # Get the events produced by this agent + events = ctx.metadata.pop("events", []) + + # In multi-Agent concurrent execution, a coroutine lock is needed here to ensure ordering + # Async network operations may yield the coroutine, causing ordering issues + + # Remove the events retained by this agent from the global session + for event in events: + if event in invocation_ctx.session.events: + invocation_ctx.session.events.remove(event) + + session_id = invocation_ctx.session.id + conversation_text = self.summarizer._extract_conversation_text(events) + + # Summarize the events produced by this agent + # create_session_summary_by_events is specifically designed for Agent-level summarization + summary_text, compressed_events = await self.summarizer.create_session_summary_by_events( + events, session_id, ctx=invocation_ctx + ) + + # Add the compressed events back to the session + if compressed_events: + invocation_ctx.session.events.extend(compressed_events) + +# Use in an Agent +def create_agent(): + agent = LlmAgent( + name="analyze", + model=model, + description="Tool for analyzing strategies", + tools=[log_set, metric_set], + filters=[AgentSessionSummarizerFilter(model)], # Configure filter + # ... + ) + return agent +``` + +**Summarization using the Filter approach**: + +1. **Record Events**: Record the events produced by this Agent +2. **Event Isolation**: Remove these events from the global session (to avoid conflicts with SessionService-level summarization) +3. **Perform Summarization**: Summarize the events +4. **Event Replacement**: Append the summarized events back to the global session + +**Comparison of the Two Summarization Approaches**: + +| Feature | SessionService-Level Summarization | Agent-Level Summarization | +|---------|-----------------------------------|--------------------------| +| **Trigger Timing** | After every N conversation turns | After each Agent execution or when text exceeds a threshold | +| **Summarization Scope** | All events in the entire Session | Events produced by a single Agent | +| **Applicable Scenarios** | Single-Agent scenarios | Multi-Agent collaboration scenarios | +| **Configuration Method** | SessionService initialization | Agent Filter configuration | +| **Advantages** | Simple to use, automatically managed | Finer-grained control, supports multiple Agents | + +--- + +## Workflow + +### 1. Summarization Trigger Conditions + +The summarizer is triggered when **user-defined trigger conditions are met**. The framework provides several built-in trigger conditions: + +- **`set_summarizer_conversation_threshold(conversation_count)`**: Sets the conversation count threshold. Summarization is performed after the conversation count reaches `conversation_count`. Default `conversation_count` is 100 +- **`set_summarizer_token_threshold(token_count)`**: Sets the session token threshold. Summarization is performed after the token count reaches `token_count` +- **`set_summarizer_events_count_threshold(event_count)`**: Sets the event count threshold. Summarization is performed after the event count reaches `event_count`. Default `event_count` is 30 +- **`set_summarizer_time_interval_threshold(time_interval)`**: Sets the time interval threshold. Summarization is performed after the conversation interval reaches `time_interval`. Default `time_interval` is 300s (5 minutes) +- **`set_summarizer_important_content_threshold(important_content_count)`**: Triggers summarization when any message part's stripped text length (after removing leading and trailing whitespace) exceeds `important_content_count` characters. Default `important_content_count` is 10 +- **`set_summarizer_check_functions_by_and(funcs: list[CheckSummarizerFunction])`**: Combined check function. Summarization is performed when all functions in `funcs` return True (AND logic) +- **`set_summarizer_check_functions_by_or(funcs: list[CheckSummarizerFunction])`**: Combined check function. Summarization is performed when any function in `funcs` returns True (OR logic) + +**Trigger Logic**: + +- When multiple check functions exist, **AND logic is used by default**, meaning summarization occurs only when all functions return True +- You can explicitly specify the logic using `set_summarizer_check_functions_by_and` or `set_summarizer_check_functions_by_or` + +--- + +### 2. Summary Generation + +Summary generation uses the default prompt template: + +``` +Please summarize the following conversation, focusing on: +1. Key decisions made +2. Important information shared +3. Actions taken or planned +4. Context that should be remembered for future interactions + +Keep the summary concise but comprehensive. Focus on what would be most important to remember for continuing the conversation. + +Conversation: +{conversation_text} + +Summary: +``` + +**Custom Prompt Template**: + +To replace the default prompt template, use the following approach: + +```python +from textwrap import dedent + +your_summarizer_prompt = dedent("""\ +Please summarize the following conversation, focusing on: +1. Key decisions +2. Important information +3. Action plans + +Conversation: +{conversation_text} + +Summary:""") + +# conversation_text represents the conversation content; this placeholder is required +summarizer = SessionSummarizer( + model=model, + summarizer_prompt=your_summarizer_prompt, + # ... +) +``` + +--- + +## Output Analysis + +**Complete Example Output**: Refer to [`examples/session_summarizer/README.md`](../../../examples/session_summarizer/README.md) + +### Key Observations + +#### 1️⃣ **Automatic Summarization Triggering** + +``` +After turn 4 → Summarization triggered (configured with set_summarizer_conversation_threshold(3)) +After turn 7 → Summarization triggered +After turn 13 → Summarization triggered +``` + +**Explanation**: +- Configured with `set_summarizer_conversation_threshold(3)`, summarization is triggered after every 3 conversation turns +- Summarization is automatically performed when the conversation turn count reaches the threshold + +#### 2️⃣ **Event Compression Results** + +| Turn | Original Event Count | Compressed Event Count | Compression Ratio | +|------|---------------------|----------------------|-------------------| +| Turn 4 | 8 | 5 | 37.5% | +| Turn 7 | 8 | 5 | 37.5% | +| Turn 13 | 13 | 5 | 61.5% | +| Manual Summary | 39 | 5 | 87.2% | + +**Explanation**: +- `keep_recent_count=4` is configured to retain the most recent 4 conversation turns (8 events: 4 turns × 2 events/turn) +- Older conversations are compressed into summary text +- The compression ratio gradually increases as the conversation progresses + +#### 3️⃣ **Summary Content Quality** + +The summary text contains: +- ✅ **Key Decisions**: User's learning choices and plans +- ✅ **Important Information**: Python concepts and knowledge points +- ✅ **Action Plans**: Project practice and learning paths + +**Explanation**: +- The LLM-generated summary retains the core information of the conversation +- The summary text is clearly formatted, facilitating subsequent retrieval and usage + +--- + +## Best Practices + +### 1. Configuration Tuning + +- Adjust the conversation count in `set_summarizer_conversation_threshold` based on conversation frequency +- Adjust `keep_recent_count` based on memory constraints +- Adjust `max_summary_length` based on model capabilities + +### 2. Content Filtering + +- Use `skip_summarization` to mark unimportant debug information +- Filter out system events before summarization +- Preserve user intent and key decisions + +### 3. Cost Control + +- Choose an appropriate model to balance quality and cost +- Implement summary caching to reduce redundant computation +- Monitor API call frequency and costs + +### 4. Multi-Agent Scenarios + +- Use Agent-level summarization (Filter approach) to avoid conflicts +- Configure different summarization strategies for different Agents +- Ensure concurrency safety by adding coroutine locks when necessary + +--- + +## FAQ + +### Q: Will summarization lose important information? + +A: The summarizer is specifically designed to preserve key information, including decisions, important data, and context. It is recommended to retain sufficient recent events via the `keep_recent_count` parameter. + +### Q: How to avoid over-summarization? + +A: Adjust the `set_summarizer_conversation_threshold` parameter to control summarization frequency, and use `skip_summarization` to mark events that should not be summarized. + +### Q: What happens if summarization fails? + +A: The summarizer includes error handling mechanisms. On failure, it returns the original session without affecting the normal conversation flow. + +### Q: How to evaluate summary quality? + +A: Summary quality can be evaluated using metrics such as compression ratio, information coverage, and user feedback. + +### Q: API call failure + +A: Perform the following checks: +- Verify that the API key is correct +- Confirm that the network connection is functioning properly +- Verify that the model name is correct + +### Q: Poor summary quality + +A: Solutions: +- Adjust the `max_summary_length` parameter +- Use a higher-quality model (e.g., GPT-4) +- Ensure the conversation content contains sufficient information +- Customize the `summarizer_prompt` template + +### Q: Low compression ratio + +A: Solutions: +- Adjust the `keep_recent_count` parameter +- Lower the conversation summarization threshold set by `set_summarizer_conversation_threshold` for more frequent summarization +- Check whether too many events are marked to skip summarization + +### Q: Summarizing data from a specific Agent + +A: Solution: Refer to `4. Agent-Level Summarization (Filter Approach)` in the Advanced Features section. Use `AgentSessionSummarizerFilter` for summarization within an Agent Filter. + +--- + +## Reference Implementation + +Session Summarizer references [Agno summarizer.py](https://github.com/agno-agi/agno/blob/main/libs/agno/agno/memory/v2/summarizer.py) as a starting point, with the following key differences: + +- **Data Structure**: TRPC Agent uses a more complex Event structure +- **Model Invocation**: Uses LlmRequest and generate_async +- **Integration**: Deep integration with Session Service +- **Configuration Options**: Provides more customization options +- **Multi-Agent Support**: Supports Agent-level summarization (Filter approach) + +--- + +## Complete Examples + +See the complete summarization usage examples: + +- 📁 **Example Code**: [`examples/session_summarizer/run_agent.py`](../../../examples/session_summarizer/run_agent.py) +- 📁 **Example Documentation**: [`examples/session_summarizer/README.md`](../../../examples/session_summarizer/README.md) +- 📁 **Agent Filter Implementation**: [`examples/session_summarizer/agent/filters.py`](../../../examples/session_summarizer/agent/filters.py) + +The examples demonstrate two summarization approaches: + +1. **SessionService-Level Summarization**: Uses `SummarizerSessionManager` for automatic summarization at the session service level +2. **Agent-Level Summarization**: Uses `AgentSessionSummarizerFilter` for summarization within an Agent Filter + +Both approaches can be combined. Choose the most suitable approach based on your actual requirements. diff --git a/docs/mkdocs/en/skill.md b/docs/mkdocs/en/skill.md new file mode 100644 index 000000000..60a9120e1 --- /dev/null +++ b/docs/mkdocs/en/skill.md @@ -0,0 +1,2198 @@ +# Skill (Agent Skills) + +Agent Skills let you package reusable workflows into folders containing a `SKILL.md` specification file along with optional documentation and scripts. During a conversation, the agent first injects low-cost "overview" information, then loads the full body content and documentation only when truly needed, and safely runs scripts in an isolated workspace. + +Background references: +- Engineering blog: + https://www.anthropic.com/engineering/equipping-agents-for-the-real-world-with-agent-skills +- Open Skills repository (reference structure): + https://github.com/anthropics/skills + +## Overview + +### 🎯 Features + +- 🔎 Overview injection (name + description) to guide selection +- 📥 `skill_load` fetches `SKILL.md` body and selected documentation on demand, automatically loading tools defined in the skill +- 📋 `skill_list` lists all available skill names +- 🔧 `skill_list_tools` lists tool names defined in a specified skill's `SKILL.md` +- ⚙️ `skill_select_tools` dynamically selects skill tools (add/replace/clear modes) for token optimization +- 📚 `skill_select_docs` adds/replaces/clears documentation +- 🧾 `skill_list_docs` lists available documentation +- 🏃 `skill_run` executes commands and returns stdout/stderr and output files +- 🗂️ Collects output files with MIME type detection support +- 🧩 Pluggable local or container workspace executors (local by default) +- 🧱 Custom working directory where skill run input files, output files, and skill files can be placed +- 🎯 Dynamic tool loading that automatically provides relevant tools based on skill selection, saving LLM tokens + +### Three-Layer Information Model + +Agent Skills adopt a three-layer information model that enables on-demand loading while keeping prompts concise: + +**1) Initial "Overview" Layer (extremely low cost)** + - Only injects the `name` and `description` from `SKILL.md` into the system message + - Lets the model know which skills are available without loading full content + +**2) Full Body Layer (loaded on demand)** + - When a task truly requires a skill, the model calls `skill_load` + - The framework then injects the complete `SKILL.md` body content for that skill + +**3) Documentation/Script Layer (selective + isolated execution) / Tool Invocation** + - Documentation is included only when explicitly requested + - Scripts are not inlined into the prompt but executed in an isolated workspace + - Only execution results and output files are returned, without exposing script source code + - Parses user-configured available tools + +### File Layout + +``` +skills/ + demo-skill/ + SKILL.md # YAML (name/description) + Markdown body + USAGE.md # optional docs (.md/.txt) + scripts/build.sh + reference/ # Reference documentation + ... +``` + +Repository and parsing: [trpc_agent_sdk/skills/_repository.py](../../../trpc_agent_sdk/skills/_repository.py) + +## Quick Start + +### 1) Requirements + +- Python 3.12 +- Model provider API key (OpenAI-compatible) +- Optional Docker (for container executor) + +Common environment variables: + +```bash +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +# Optional: specify the skills directory, supports local paths or URLs (see "URL-based Skills Root") +export SKILLS_ROOT=/path/to/skills +# Optional: override the cache directory for URL-based Skills Root +export SKILLS_CACHE_DIR=/path/to/cache +``` + +Alternatively, you can use a `.env` file (examples automatically load it via `python-dotenv`): + +```bash +# .env file +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name +SKILLS_ROOT=./skills +# Optional: SKILLS_ROOT can also be a URL, for example: +# SKILLS_ROOT=https://example.com/my-skills.tar.gz +# SKILLS_CACHE_DIR=/custom/cache/path +``` + +### 2) Enabling Skills in an Agent + +Create a skill repository and a workspace executor. If no executor is specified, the local executor is used by default for development convenience. + +```python +import os +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.skills import SkillToolSet +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.tools import LinkSkillStager +from trpc_agent_sdk.code_executors import create_local_workspace_runtime +from trpc_agent_sdk.code_executors import create_container_workspace_runtime +# Cube is an optional extra (`pip install 'trpc-agent-py[cube]'`); import lazily. +# from trpc_agent_sdk.code_executors.cube import CubeCodeExecutor, CubeCodeExecutorConfig +# from trpc_agent_sdk.code_executors.cube import create_cube_workspace_runtime + +# Create workspace runtime (local, container, or cube) +workspace_runtime = create_local_workspace_runtime() +# Or use container: workspace_runtime = create_container_workspace_runtime() +# Or use a remote Cube/E2B sandbox: +# executor = await CubeCodeExecutor.create(CubeCodeExecutorConfig()) +# workspace_runtime = create_cube_workspace_runtime(executor) + +# Create skill repository +repository = create_default_skill_repository("./skills", workspace_runtime=workspace_runtime, use_cached_repository=True) + +# Create skill tool set with optional artifact save options +skill_tool_set = SkillToolSet( + repository=repository, + skill_stager=LinkSkillStager(), + # run_tool_kwargs is an optional tool parameter + run_tool_kwargs={ + "save_as_artifacts": True, # Whether to save as artifact files + "omit_inline_content": False, + } +) + +# Create an agent with skills +agent = LlmAgent( + name="skill_run_agent", + description="A professional skill run assistant that can use Agent Skills.", + model=_create_model(), + instruction=INSTRUCTION, # Prompt containing skill usage guidance + tools=[skill_tool_set], + skill_repository=repository, + ) +``` + +*Note: Starting after version 1.1.10, skill loading and injection were optimized to support caching skill content and symlink-based staging in the local sandbox, avoiding full directory copies.* + +**Prompt example**: + +The `INSTRUCTION` should include complete skill usage workflow guidance: + +```python +INSTRUCTION = """ +You are an AI assistant with access to Agent Skills. + +## Complete Skill Workflow + +When handling user requests: + +1. **Discover** → Call skill_list() to see available skills +2. **Inspect** → Call skill_list_tools(skill_name="...") to preview tools +3. **Load** → Call skill_load(skill_name="...") to load the skill +4. **Optimize** → Call skill_select_tools(...) to select only needed tools (saves tokens) +5. **Document** → Call skill_list_docs(...) and skill_select_docs(...) if more info needed +6. **Execute** → Call skill_run(...) to execute commands or use skill's tools directly + +Example Complete Flow: +User: "What's the weather in Beijing?" +→ skill_list() → see "weather-tools" +→ skill_list_tools(skill_name="weather-tools") → see available tools +→ skill_load(skill_name="weather-tools") → load full content +→ skill_select_tools(skill_name="weather-tools", tools=["get_current_weather"]) → optimize +→ get_current_weather(city="Beijing") → execute + +Always use environment variables in commands: +- $WORKSPACE_DIR, $SKILLS_DIR, $WORK_DIR, $OUTPUT_DIR, $RUN_DIR, $SKILL_NAME +""" +``` + +Key points: +- **Automatic tool registration**: The following tools are automatically registered via `SkillToolSet`, requiring no manual wiring: + - `skill_list`: Lists all available skills + - `skill_list_tools`: Lists tools of a skill + - `skill_load`: Loads skill content + - `skill_select_tools`: Selects specific tools (token optimization) + - `skill_list_docs`: Lists available documentation + - `skill_select_docs`: Selects specific documentation + - `skill_run`: Executes skill commands +- **Intelligent prompt guidance**: Explicitly describe the workflow in the prompt to guide the LLM to call tools in the correct order +- **Token optimization**: Use `skill_select_tools` to load only the needed tools, significantly reducing context size +- **Code location**: + - Package entry (aggregated exports): [trpc_agent_sdk/skills/tools/__init__.py](../../../trpc_agent_sdk/skills/tools/__init__.py) + - `skill_run` implementation: [trpc_agent_sdk/skills/tools/_skill_run.py](../../../trpc_agent_sdk/skills/tools/_skill_run.py) (for other tools, see **Declaration location** in each section below) + +### 3) Running the Example + +Full interactive demo: [examples/skills/run_agent.py](../../../examples/skills/run_agent.py) + +The example is organized in a modular structure: +- `agent/agent.py` - Agent creation +- `agent/tools.py` - Skill tool set creation +- `agent/config.py` - Model configuration from environment variables +- `agent/prompts.py` - Agent instruction prompts +- `run_agent.py` - Main entry file + +```bash +cd examples/skills + +# Set environment variables +export TRPC_AGENT_API_KEY="your-api-key" +export TRPC_AGENT_BASE_URL="your-base-url" +export TRPC_AGENT_MODEL_NAME="your-model-name" +export SKILLS_ROOT="./skills" # Optional, defaults to ./skills + +# Run the example +python3 run_agent.py +``` + +Or use a `.env` file: + +```bash +# Create .env file +cat > .env << EOF +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name +SKILLS_ROOT=./skills +EOF + +# Run (automatically loads .env) +python3 run_agent.py +``` + +Example skill (excerpt): +[examples/skills/skills/python-math/SKILL.md](../../../examples/skills/skills/python-math/SKILL.md) + +Tips: +- Describe the task you want to accomplish; the model will decide whether a skill is needed based on the overview. +- When needed, the model will call `skill_load` to fetch the body/documentation, then call `skill_run` to execute and return output files. + +#### Example run output + +Using user-file-ops as an example: +```txt +🆔 Session ID: be355f8f... +📝 User: + I have a text file at /tmp/skillrun-notes.txt. + Please use the user-file-ops skill to summarize it, you can use command `cp` to copy it to the workspace, + then mapping it to `work/inputs/user-notes.txt` and writing the summary to `out/user-notes-summary.txt` + +🤖 Assistant: +🔧 [Invoke Tool:: skill_load({'skill_name': 'user-file-ops'})] +📊 [Tool Result: {'result': "skill 'user-file-ops' loaded"}] + +🔧 [Invoke Tool:: skill_list_docs({'skill_name': 'user-file-ops'})] +📊 [Tool Result: {}] + +🔧 [Invoke Tool:: skill_run({'skill': 'user-file-ops', 'command': 'cp /tmp/skillrun-notes.txt work/inputs/user-notes.txt && bash scripts/summarize_file.sh work/inputs/user-notes.txt out/user-notes-summary.txt', 'output_files': ['out/user-notes-summary.txt']})] +📊 [Tool Result: {'stdout': '', 'stderr': '', 'exit_code': 0, 'timed_out': False, 'duration_ms': 0, 'output_files': [{'name': 'out/user-notes-summary.txt', 'content': 'File: work/inputs/user-notes.txt\nLines: 2\nWords: 7\nBytes: 41\n\nFirst 20 non-empty lines:\n 1\thello from skillrun\n 2\tthis is another line\n', 'mime_type': 'text/plain'}], 'artifact_files': []}] +The file `/tmp/skillrun-notes.txt` has been summarized successfully. Here are the details: + +### Summary of `user-notes.txt`: +- **Lines**: 2 +- **Words**: 7 +- **Bytes**: 41 + +### Preview (First 20 non-empty lines): +``` +1. hello from skillrun +2. this is another line +``` + +The summary has been saved to `out/user-notes-summary.txt`. Let me know if you'd like to perform any further actions! +``` + +#### Run Directory + +Default working directory name: `/tmp/ws_-
Link') + assert "[Link](https://x.com)" in result + + def test_headings_converted(self): + tool = WebFetchTool() + result = tool._to_markdown("

Title

") + assert "# Title" in result + + def test_h3_converted(self): + tool = WebFetchTool() + result = tool._to_markdown("

Sub

") + assert "### Sub" in result + + def test_list_items(self): + tool = WebFetchTool() + result = tool._to_markdown("
  • Item1
  • Item2
  • ") + assert "- Item1" in result + assert "- Item2" in result + + def test_br_converted(self): + tool = WebFetchTool() + result = tool._to_markdown("line1
    line2") + assert "line1" in result + assert "line2" in result + + def test_p_closing_adds_newlines(self): + tool = WebFetchTool() + result = tool._to_markdown("

    para1

    para2

    ") + assert "para1" in result + assert "para2" in result diff --git a/tests/sessions/__init__.py b/tests/sessions/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/tests/sessions/__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 Apache-2.0. diff --git a/tests/sessions/test_base_session_service.py b/tests/sessions/test_base_session_service.py new file mode 100644 index 000000000..bcb63037a --- /dev/null +++ b/tests/sessions/test_base_session_service.py @@ -0,0 +1,349 @@ +# 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 trpc_agent_sdk.sessions._base_session_service. + +Covers: +- BaseSessionService: init, append_event, state updates, temp state trimming, + filter_events, summarizer delegation, close +""" + +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from trpc_agent_sdk.abc import ListSessionsResponse +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._base_session_service import BaseSessionService +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._summarizer_manager import SummarizerSessionManager +from trpc_agent_sdk.sessions._types import SessionServiceConfig +from trpc_agent_sdk.types import Content, EventActions, Part, State + + +class ConcreteSessionService(BaseSessionService): + """Minimal concrete subclass for testing BaseSessionService logic.""" + + async def create_session(self, **kwargs): + raise NotImplementedError + + async def get_session(self, **kwargs): + raise NotImplementedError + + async def list_sessions(self, **kwargs): + return ListSessionsResponse() + + async def delete_session(self, **kwargs): + pass + + +def _make_session(**kwargs) -> Session: + defaults = dict(id="s1", app_name="app", user_id="user", save_key="app/user") + defaults.update(kwargs) + return Session(**defaults) + + +def _make_event(author: str = "agent", text: str = "hello", state_delta: dict | None = None, + partial: bool = False) -> Event: + actions = EventActions(state_delta=state_delta) if state_delta else EventActions() + return Event( + invocation_id="inv-1", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + actions=actions, + partial=partial, + ) + + +class TestBaseSessionServiceInit: + """Test initialization of BaseSessionService.""" + + def test_default_init(self): + svc = ConcreteSessionService() + assert svc._summarizer_manager is None + assert svc._session_config is not None + assert svc._session_config.ttl.enable is False + + def test_init_with_config(self): + config = SessionServiceConfig(max_events=10, event_ttl_seconds=30.0) + svc = ConcreteSessionService(session_config=config) + assert svc._session_config.max_events == 10 + assert svc._session_config.event_ttl_seconds == 30.0 + + def test_init_with_summarizer(self): + mock_model = MagicMock() + mock_model.name = "test-model" + manager = SummarizerSessionManager(model=mock_model) + svc = ConcreteSessionService(summarizer_manager=manager) + assert svc.summarizer_manager is manager + + def test_init_sets_service_on_summarizer(self): + mock_model = MagicMock() + mock_model.name = "test-model" + manager = SummarizerSessionManager(model=mock_model) + svc = ConcreteSessionService(summarizer_manager=manager) + assert manager._base_service is svc + + +class TestBaseSessionServiceAppendEvent: + """Test append_event method.""" + + async def test_append_event_basic(self): + svc = ConcreteSessionService() + session = _make_session() + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + assert len(session.events) == 1 + assert session.events[0] is event + + async def test_append_event_partial_skipped(self): + svc = ConcreteSessionService() + session = _make_session() + event = _make_event(partial=True) + result = await svc.append_event(session, event) + assert result is event + assert len(session.events) == 0 + + async def test_append_event_updates_session_state(self): + svc = ConcreteSessionService() + session = _make_session() + event = _make_event(state_delta={"my_key": "my_value"}) + await svc.append_event(session, event) + assert session.state["my_key"] == "my_value" + + async def test_append_event_skips_temp_state(self): + svc = ConcreteSessionService() + session = _make_session() + event = _make_event(state_delta={f"{State.TEMP_PREFIX}temp_key": "temp_value", "regular": "value"}) + await svc.append_event(session, event) + assert session.state.get(f"{State.TEMP_PREFIX}temp_key") == "temp_value" + assert session.state.get("regular") == "value" + + async def test_append_event_no_actions(self): + svc = ConcreteSessionService() + session = _make_session() + event = Event(invocation_id="inv-1", author="agent", + content=Content(parts=[Part.from_text(text="test")]), + actions=EventActions()) + await svc.append_event(session, event) + assert len(session.events) == 1 + + async def test_append_event_empty_state_delta(self): + svc = ConcreteSessionService() + session = _make_session() + event = _make_event(state_delta={}) + await svc.append_event(session, event) + assert len(session.events) == 1 + + async def test_append_event_stores_filtered_events_when_configured(self): + config = SessionServiceConfig(max_events=2, store_historical_events=True) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + + for i in range(6): + event = _make_event(author="user" if i == 2 else "agent", text=f"msg{i}") + await svc.append_event(session, event) + + assert [event.get_text() for event in session.events] == ["msg2", "msg5"] + assert [event.get_text() for event in session.historical_events] == ["msg0", "msg1", "msg3", "msg4"] + + +class TestBaseSessionServiceTrimTempDeltaState: + """Test _trim_temp_delta_state method.""" + + def test_trim_temp_keys(self): + svc = ConcreteSessionService() + event = _make_event(state_delta={ + f"{State.TEMP_PREFIX}t1": "val1", + "normal_key": "val2", + }) + result = svc._trim_temp_delta_state(event) + assert f"{State.TEMP_PREFIX}t1" not in result.actions.state_delta + assert result.actions.state_delta["normal_key"] == "val2" + + def test_trim_no_state_delta(self): + svc = ConcreteSessionService() + event = _make_event() + result = svc._trim_temp_delta_state(event) + assert result is event + + +class TestBaseSessionServiceFilterEvents: + """Test filter_events method.""" + + def test_filter_by_num_recent_events(self): + config = SessionServiceConfig(num_recent_events=3) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + for i in range(10): + author = "user" if i == 7 else "agent" + session.events.append(_make_event(author=author, text=f"msg{i}")) + filtered_session = svc.filter_events(session) + assert filtered_session is session + assert [event.get_text() for event in session.events] == ["msg7", "msg8", "msg9"] + + def test_filter_by_num_recent_events_with_copy(self): + config = SessionServiceConfig(num_recent_events=3) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + for i in range(10): + author = "user" if i == 7 else "agent" + session.events.append(_make_event(author=author, text=f"msg{i}")) + + filtered_session = svc.filter_events(session, need_copy=True) + + assert filtered_session is not session + assert len(session.events) == 10 + assert [event.get_text() for event in filtered_session.events] == ["msg7", "msg8", "msg9"] + + def test_filter_by_event_ttl(self): + config = SessionServiceConfig(event_ttl_seconds=5.0) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + + old_event = _make_event(text="old") + old_event.timestamp = time.time() - 100 + session.events.append(old_event) + + new_event = _make_event(author="user", text="new") + new_event.timestamp = time.time() + session.events.append(new_event) + + filtered_session = svc.filter_events(session) + assert filtered_session is session + assert len(session.events) == 1 + assert session.events[0].get_text() == "new" + + def test_filter_no_config(self): + svc = ConcreteSessionService() + session = _make_session() + for i in range(5): + session.events.append(_make_event(text=f"msg{i}")) + filtered_session = svc.filter_events(session) + assert filtered_session is session + assert len(session.events) == 5 + + def test_filter_ttl_removes_all_old(self): + config = SessionServiceConfig(event_ttl_seconds=1.0) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + for i in range(5): + e = _make_event(text=f"old{i}") + e.timestamp = time.time() - 100 + session.events.append(e) + filtered_session = svc.filter_events(session) + assert filtered_session is session + assert session.events == [] + + def test_filter_by_num_recent_events_preserves_summary_anchor(self): + config = SessionServiceConfig(num_recent_events=3) + svc = ConcreteSessionService(session_config=config) + session = _make_session() + + summary_event = _make_event(author="system", text="summary") + summary_event.set_summary_event(True) + session.events.append(summary_event) + for i in range(5): + session.events.append(_make_event(text=f"agent{i}")) + + filtered_session = svc.filter_events(session) + + assert filtered_session is session + assert len(session.events) == 1 + assert session.events[0].is_summary_event() + + +class TestBaseSessionServiceSetSummarizerManager: + """Test set_summarizer_manager method.""" + + def test_set_when_none(self): + svc = ConcreteSessionService() + mock_model = MagicMock() + mock_model.name = "test" + manager = SummarizerSessionManager(model=mock_model) + svc.set_summarizer_manager(manager) + assert svc.summarizer_manager is manager + + def test_set_does_not_overwrite(self): + mock_model = MagicMock() + mock_model.name = "test" + manager1 = SummarizerSessionManager(model=mock_model) + manager2 = SummarizerSessionManager(model=mock_model) + svc = ConcreteSessionService(summarizer_manager=manager1) + svc.set_summarizer_manager(manager2) + assert svc.summarizer_manager is manager1 + + def test_set_force_overwrites(self): + mock_model = MagicMock() + mock_model.name = "test" + manager1 = SummarizerSessionManager(model=mock_model) + manager2 = SummarizerSessionManager(model=mock_model) + svc = ConcreteSessionService(summarizer_manager=manager1) + svc.set_summarizer_manager(manager2, force=True) + assert svc.summarizer_manager is manager2 + + +class TestBaseSessionServiceSummarization: + """Test create_session_summary and get_session_summary.""" + + async def test_create_session_summary_with_manager(self): + mock_model = MagicMock() + mock_model.name = "test" + manager = SummarizerSessionManager(model=mock_model) + manager.create_session_summary = AsyncMock() + svc = ConcreteSessionService(summarizer_manager=manager) + session = _make_session() + await svc.create_session_summary(session) + manager.create_session_summary.assert_called_once() + + async def test_create_session_summary_no_manager(self): + svc = ConcreteSessionService() + session = _make_session() + await svc.create_session_summary(session) + + async def test_get_session_summary_with_manager(self): + mock_model = MagicMock() + mock_model.name = "test" + manager = SummarizerSessionManager(model=mock_model) + mock_summary = MagicMock() + mock_summary.summary_text = "This is a summary" + manager.get_session_summary = AsyncMock(return_value=mock_summary) + svc = ConcreteSessionService(summarizer_manager=manager) + session = _make_session() + result = await svc.get_session_summary(session) + assert result == "This is a summary" + + async def test_get_session_summary_no_manager(self): + svc = ConcreteSessionService() + session = _make_session() + result = await svc.get_session_summary(session) + assert result is None + + async def test_get_session_summary_returns_none(self): + mock_model = MagicMock() + mock_model.name = "test" + manager = SummarizerSessionManager(model=mock_model) + manager.get_session_summary = AsyncMock(return_value=None) + svc = ConcreteSessionService(summarizer_manager=manager) + session = _make_session() + result = await svc.get_session_summary(session) + assert result is None + + +class TestBaseSessionServiceUpdateAndClose: + """Test update_session and close.""" + + async def test_update_session_default_noop(self): + svc = ConcreteSessionService() + session = _make_session() + await svc.update_session(session) + + async def test_close(self): + svc = ConcreteSessionService() + await svc.close() diff --git a/tests/sessions/test_history_record.py b/tests/sessions/test_history_record.py new file mode 100644 index 000000000..13c6a3e61 --- /dev/null +++ b/tests/sessions/test_history_record.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 Apache-2.0. +"""Unit tests for trpc_agent_sdk.sessions._history_record. + +Covers: +- HistoryRecord: add_record, build_content, validation +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.sessions._history_record import HistoryRecord + + +class TestHistoryRecordDefaults: + """Test HistoryRecord creation and defaults.""" + + def test_default_empty_lists(self): + record = HistoryRecord() + assert record.user_texts == [] + assert record.assistant_texts == [] + + +class TestHistoryRecordAddRecord: + """Test add_record method.""" + + def test_add_user_only(self): + record = HistoryRecord() + record.add_record("Hello") + assert len(record.user_texts) == 1 + assert record.user_texts[0] == "user: Hello" + assert len(record.assistant_texts) == 0 + + def test_add_user_and_assistant(self): + record = HistoryRecord() + record.add_record("Hello", "Hi there") + assert record.user_texts[0] == "user: Hello" + assert record.assistant_texts[0] == "assistant: Hi there" + + def test_user_text_already_prefixed(self): + record = HistoryRecord() + record.add_record("user: Hello") + assert record.user_texts[0] == "user: Hello" + + def test_assistant_text_already_prefixed(self): + record = HistoryRecord() + record.add_record("Hello", "assistant: Hi") + assert record.assistant_texts[0] == "assistant: Hi" + + def test_add_multiple_records(self): + record = HistoryRecord() + record.add_record("Q1", "A1") + record.add_record("Q2", "A2") + assert len(record.user_texts) == 2 + assert len(record.assistant_texts) == 2 + + def test_add_record_empty_user_with_assistant_raises(self): + record = HistoryRecord() + with pytest.raises(ValueError, match="when user text is empty"): + record.add_record("", "some assistant text") + + def test_add_record_empty_assistant_text(self): + record = HistoryRecord() + record.add_record("Hello", "") + assert len(record.user_texts) == 1 + assert len(record.assistant_texts) == 0 + + def test_add_record_none_assistant_text(self): + record = HistoryRecord() + record.add_record("Hello", None) + assert len(record.user_texts) == 1 + assert len(record.assistant_texts) == 0 + + +class TestHistoryRecordBuildContent: + """Test build_content method.""" + + def test_build_content_single_pair(self): + record = HistoryRecord() + record.add_record("Hello", "Hi there") + content = record.build_content("Next question") + assert content.role == "user" + assert len(content.parts) == 3 + assert content.parts[0].text == "user: Hello" + assert content.parts[1].text == "assistant: Hi there" + assert content.parts[2].text == "Next question" + + def test_build_content_multiple_pairs(self): + record = HistoryRecord() + record.add_record("Q1", "A1") + record.add_record("Q2", "A2") + content = record.build_content("Q3") + assert len(content.parts) == 5 + + def test_build_content_with_unanswered_question(self): + record = HistoryRecord() + record.add_record("Q1", "A1") + record.add_record("Q2") + content = record.build_content("follow-up") + assert len(content.parts) == 4 + assert content.parts[0].text == "user: Q1" + assert content.parts[1].text == "assistant: A1" + assert content.parts[2].text == "user: Q2" + assert content.parts[3].text == "follow-up" + + def test_build_content_empty_user_message(self): + record = HistoryRecord() + record.add_record("Hello", "Hi") + content = record.build_content("") + assert content.parts[-1].text == "" + + def test_build_content_raises_if_more_assistants_than_users(self): + record = HistoryRecord() + record.user_texts = ["user: Q1"] + record.assistant_texts = ["assistant: A1", "assistant: A2"] + with pytest.raises(ValueError, match="user texts must more than assistant texts"): + record.build_content("test") + + def test_build_content_no_records(self): + record = HistoryRecord() + content = record.build_content("Hello") + assert len(content.parts) == 1 + assert content.parts[0].text == "Hello" diff --git a/tests/sessions/test_in_memory_session_service.py b/tests/sessions/test_in_memory_session_service.py new file mode 100644 index 000000000..6b33bcd20 --- /dev/null +++ b/tests/sessions/test_in_memory_session_service.py @@ -0,0 +1,550 @@ +# 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 trpc_agent_sdk.sessions._in_memory_session_service. + +Covers: +- SessionWithTTL: update, get, expiration +- StateWithTTL: update, get, expiration +- InMemorySessionService: create_session, get_session, list_sessions, delete_session, + append_event, update_session, state management, cleanup, close +""" + +from __future__ import annotations + +import asyncio +import time +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._in_memory_session_service import ( + InMemorySessionService, + SessionWithTTL, + StateWithTTL, +) +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._types import SessionServiceConfig +from trpc_agent_sdk.types import Content, EventActions, Part, State, Ttl + + +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) + else: + config.clean_ttl_config() + return config + + +def _make_event(author="agent", text="hello", state_delta=None, partial=False): + actions = EventActions(state_delta=state_delta) if state_delta else EventActions() + return Event( + invocation_id="inv-1", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + actions=actions, + partial=partial, + ) + + +# --------------------------------------------------------------------------- +# 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) + new_session = Session(id="s2", app_name="app", user_id="user", save_key="k") + wrapper.update(new_session) + assert wrapper.get().id == "s2" + + def test_get_expired_returns_none(self): + ttl = Ttl(enable=True, ttl_seconds=1, cleanup_interval_seconds=60.0) + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + wrapper = SessionWithTTL(session=session, ttl=ttl) + wrapper.ttl.update_time = time.time() - 100 + result = wrapper.get() + assert result is None + + def test_get_non_expired(self): + ttl = Ttl(enable=True, ttl_seconds=9999, cleanup_interval_seconds=60.0) + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + wrapper = SessionWithTTL(session=session, ttl=ttl) + result = wrapper.get() + assert result is not None + assert result.id == "s1" + + +# --------------------------------------------------------------------------- +# StateWithTTL +# --------------------------------------------------------------------------- + +class TestStateWithTTL: + def test_update(self): + wrapper = StateWithTTL() + result = wrapper.update({"key": "value"}) + assert result == {"key": "value"} + + def test_get(self): + wrapper = StateWithTTL(data={"key": "value"}) + assert wrapper.get() == {"key": "value"} + + def test_update_merges(self): + wrapper = StateWithTTL(data={"a": 1}) + result = wrapper.update({"b": 2}) + assert result == {"a": 1, "b": 2} + + def test_get_expired_returns_empty(self): + ttl = Ttl(enable=True, ttl_seconds=1, cleanup_interval_seconds=60.0) + wrapper = StateWithTTL(data={"key": "value"}, ttl=ttl) + wrapper.ttl.update_time = time.time() - 100 + assert wrapper.get() == {} + + def test_update_expired_resets_then_updates(self): + ttl = Ttl(enable=True, ttl_seconds=1, cleanup_interval_seconds=60.0) + wrapper = StateWithTTL(data={"old": "data"}, ttl=ttl) + wrapper.ttl.update_time = time.time() - 100 + result = wrapper.update({"new": "data"}) + assert result == {"new": "data"} + assert "old" not in result + + +# --------------------------------------------------------------------------- +# 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") + assert session.app_name == "app" + assert session.user_id == "user" + assert session.id is not None + await svc.close() + + async def test_create_with_custom_id(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", session_id="custom-id") + assert session.id == "custom-id" + await svc.close() + + async def test_create_with_whitespace_id_generates_uuid(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", session_id=" ") + assert len(session.id) > 0 + assert session.id.strip() == session.id + await svc.close() + + 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", + }) + 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" + await svc.close() + + async def test_create_returns_deep_copy(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", state={"key": "val"}) + session.state["key"] = "modified" + stored = await svc.get_session(app_name="app", user_id="user", session_id=session.id) + assert stored.state["key"] == "val" + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is not None + assert result.id == "s1" + await svc.close() + + async def test_get_nonexistent_session(self): + svc = InMemorySessionService(session_config=_make_session_config()) + result = await svc.get_session(app_name="app", user_id="user", session_id="nonexistent") + assert result is None + await svc.close() + + 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", + }) + 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" + assert result.state[f"{State.USER_PREFIX}uk"] == "uv" + await svc.close() + + async def test_get_returns_deep_copy(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + s1 = await svc.get_session(app_name="app", user_id="user", session_id="s1") + s1.state["mutated"] = True + s2 = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert "mutated" not in s2.state + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + assert result.sessions == [] + await svc.close() + + async def test_list_multiple(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + await svc.create_session(app_name="app", user_id="user", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id="user") + assert len(result.sessions) == 2 + await svc.close() + + async def test_list_sessions_have_no_events(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event() + await svc.append_event(session, event) + result = await svc.list_sessions(app_name="app", user_id="user") + assert len(result.sessions) == 1 + assert result.sessions[0].events == [] + assert result.sessions[0].historical_events == [] + await svc.close() + + async def test_list_nonexistent_app(self): + svc = InMemorySessionService(session_config=_make_session_config()) + result = await svc.list_sessions(app_name="nonexistent", user_id="user") + assert result.sessions == [] + await svc.close() + + async def test_list_nonexistent_user(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user1", session_id="s1") + result = await svc.list_sessions(app_name="app", user_id="user2") + assert result.sessions == [] + 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") + await svc.delete_session(app_name="app", user_id="user", session_id="s1") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is None + await svc.close() + + async def test_delete_nonexistent(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.delete_session(app_name="app", user_id="user", session_id="nonexistent") + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 1 + await svc.close() + + async def test_append_partial_skipped(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event(partial=True) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 0 + await svc.close() + + async def test_append_with_state_delta(self): + svc = InMemorySessionService(session_config=_make_session_config()) + 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", + }) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert stored.state["session_key"] == "sv" + assert stored.state[f"{State.APP_PREFIX}app_key"] == "av" + assert stored.state[f"{State.USER_PREFIX}user_key"] == "uv" + await svc.close() + + async def test_append_to_nonexistent_app(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = Session(id="s1", app_name="nonexistent", user_id="user", save_key="k") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + await svc.close() + + async def test_append_to_nonexistent_user(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user1", session_id="s1") + session = Session(id="s1", app_name="app", user_id="nonexistent", save_key="k") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + await svc.close() + + async def test_append_to_nonexistent_session(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + session = Session(id="nonexistent", app_name="app", user_id="user", save_key="k") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + await svc.close() + + async def test_append_updates_conversation_count(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + session.conversation_count = 5 + event = _make_event() + await svc.append_event(session, event) + stored_session = svc._get_session("app", "user", "s1") + assert stored_session.conversation_count == 5 + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + session.state["new_key"] = "new_value" + await svc.update_session(session) + await svc.close() + + async def test_update_nonexistent_app(self): + svc = InMemorySessionService(session_config=_make_session_config()) + session = Session(id="s1", app_name="nonexistent", user_id="user", save_key="k") + await svc.update_session(session) + await svc.close() + + async def test_update_nonexistent_user(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + session = Session(id="s1", app_name="app", user_id="nonexistent", save_key="k") + await svc.update_session(session) + await svc.close() + + async def test_update_nonexistent_session(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + session = Session(id="nonexistent", app_name="app", user_id="user", save_key="k") + await svc.update_session(session) + await svc.close() + + +# --------------------------------------------------------------------------- +# 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) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + + for app_sessions in svc._sessions.values(): + for user_sessions in app_sessions.values(): + for s_ttl in user_sessions.values(): + s_ttl.ttl.update_time = time.time() - 100 + + svc._cleanup_expired() + assert "app" not in svc._sessions + await svc.close() + + async def test_cleanup_removes_expired_app_state(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=1, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + await svc.create_session(app_name="app", user_id="user", state={f"{State.APP_PREFIX}k": "v"}) + svc._app_state["app"].ttl.update_time = time.time() - 100 + svc._cleanup_expired() + assert "app" not in svc._app_state + await svc.close() + + async def test_cleanup_removes_expired_user_state(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=1, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + await svc.create_session(app_name="app", user_id="user", state={f"{State.USER_PREFIX}k": "v"}) + svc._user_state["app"]["user"].ttl.update_time = time.time() - 100 + svc._cleanup_expired() + assert "app" not in svc._user_state + await svc.close() + + async def test_cleanup_keeps_non_expired(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=9999, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + svc._cleanup_expired() + assert svc._get_session("app", "user", "s1") is not None + await svc.close() + + async def test_cleanup_nothing_expired(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=9999, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + svc._cleanup_expired() + await svc.close() + + +# --------------------------------------------------------------------------- +# 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 + + async def test_cleanup_task_created_with_ttl(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + assert svc._InMemorySessionService__cleanup_task is not None + await svc.close() + + async def test_stop_cleanup_task(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + svc._stop_cleanup_task() + assert svc._InMemorySessionService__cleanup_task is None + assert svc._InMemorySessionService__cleanup_stop_event is None + + async def test_stop_cleanup_when_no_task(self): + svc = InMemorySessionService(session_config=_make_session_config()) + svc._stop_cleanup_task() + + async def test_close_stops_cleanup(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + await svc.close() + assert svc._InMemorySessionService__cleanup_task is None + + async def test_start_cleanup_idempotent(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = InMemorySessionService(session_config=config) + task = svc._InMemorySessionService__cleanup_task + svc._start_cleanup_task() + assert svc._InMemorySessionService__cleanup_task is task + await svc.close() + + async def test_cleanup_loop_runs_and_stops(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=0.05) + svc = InMemorySessionService(session_config=config) + await asyncio.sleep(0.15) + await svc.close() + + async def test_cleanup_loop_handles_error(self): + config = _make_session_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=0.05) + svc = InMemorySessionService(session_config=config) + with patch.object(svc, "_cleanup_expired", side_effect=RuntimeError("test")): + await asyncio.sleep(0.15) + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") == {} + await svc.close() + + async def test_get_user_state_nonexistent_app(self): + svc = InMemorySessionService(session_config=_make_session_config()) + assert svc._get_user_state("nonexistent", "user") == {} + await svc.close() + + async def test_get_user_state_nonexistent_user(self): + svc = InMemorySessionService(session_config=_make_session_config()) + svc._user_state["app"] = {} + assert svc._get_user_state("app", "nonexistent") == {} + await svc.close() + + async def test_get_session_nonexistent(self): + svc = InMemorySessionService(session_config=_make_session_config()) + assert svc._get_session("a", "b", "c") is None + await svc.close() + + async def test_is_session_exist_false(self): + svc = InMemorySessionService(session_config=_make_session_config()) + assert svc._is_session_exist("a", "b", "c") is False + await svc.close() + + async def test_is_session_exist_true(self): + svc = InMemorySessionService(session_config=_make_session_config()) + await svc.create_session(app_name="app", user_id="user", session_id="s1") + assert svc._is_session_exist("app", "user", "s1") is True + await svc.close() + + async def test_update_app_state_new(self): + svc = InMemorySessionService(session_config=_make_session_config()) + result = svc._update_app_state("app", {"key": "val"}) + assert result == {"key": "val"} + await svc.close() + + async def test_update_app_state_existing(self): + svc = InMemorySessionService(session_config=_make_session_config()) + svc._update_app_state("app", {"k1": "v1"}) + result = svc._update_app_state("app", {"k2": "v2"}) + assert result == {"k1": "v1", "k2": "v2"} + await svc.close() + + async def test_update_user_state_new(self): + svc = InMemorySessionService(session_config=_make_session_config()) + result = svc._update_user_state("app", "user", {"key": "val"}) + assert result == {"key": "val"} + await svc.close() + + async def test_update_user_state_existing(self): + svc = InMemorySessionService(session_config=_make_session_config()) + svc._update_user_state("app", "user", {"k1": "v1"}) + result = svc._update_user_state("app", "user", {"k2": "v2"}) + assert result == {"k1": "v1", "k2": "v2"} + await svc.close() diff --git a/tests/sessions/test_redis_session_service.py b/tests/sessions/test_redis_session_service.py new file mode 100644 index 000000000..963fe218a --- /dev/null +++ b/tests/sessions/test_redis_session_service.py @@ -0,0 +1,345 @@ +# 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 trpc_agent_sdk.sessions._redis_session_service. + +Covers: +- RedisSessionService: create_session, get_session, list_sessions, delete_session, + append_event, update_session, close, TTL refresh +All Redis I/O is mocked via RedisStorage. +""" + +from __future__ import annotations + +import json +import time +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._redis_session_service import RedisSessionService +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._types import SessionServiceConfig +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): + 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) + else: + config.clean_ttl_config() + return config + + +def _make_event(author="agent", text="hello", state_delta=None, partial=False): + actions = EventActions(state_delta=state_delta) if state_delta else EventActions() + return Event( + invocation_id="inv-1", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + actions=actions, + partial=partial, + ) + + +def _make_session_obj(**kwargs): + defaults = dict(id="s1", app_name="app", user_id="user", save_key="app/user", last_update_time=time.time()) + defaults.update(kwargs) + return Session(**defaults) + + +class _MockRedisStorage: + """Mock RedisStorage that stores data in memory.""" + + def __init__(self): + self._store = {} + self._hash_store = {} + + @asynccontextmanager + async def create_db_session(self): + yield MagicMock() + + async def execute_command(self, session, command): + method = command.method + args = command.args + + if method == 'set': + key, value = args[0], args[1] + self._store[key] = value + return True + elif method == 'get': + key = args[0] + return self._store.get(key) + elif method == 'keys': + pattern = args[0] + prefix = pattern.replace("*", "") + return [k for k in self._store.keys() if k.startswith(prefix)] + elif method == 'hset': + key = args[0] + pairs = args[1:] + if key not in self._hash_store: + self._hash_store[key] = {} + for i in range(0, len(pairs), 2): + self._hash_store[key][pairs[i]] = pairs[i + 1] + return True + elif method == 'hgetall': + key = args[0] + return self._hash_store.get(key, {}) + return None + + async def delete(self, session, key): + self._store.pop(key, None) + + async def expire(self, session, expire_obj): + pass + + async def close(self): + pass + + +def _create_service(config=None): + """Create a RedisSessionService with mocked storage.""" + config = config or _make_config() + with patch("trpc_agent_sdk.sessions._redis_session_service.RedisStorage"): + svc = RedisSessionService(db_url="redis://localhost:6379", session_config=config) + svc._redis_storage = _MockRedisStorage() + return svc + + +class TestRedisCreateSession: + async def test_create_basic(self): + svc = _create_service() + session = await svc.create_session(app_name="app", user_id="user") + assert session.app_name == "app" + assert session.user_id == "user" + assert session.id is not None + await svc.close() + + async def test_create_with_custom_id(self): + svc = _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="custom-id") + assert session.id == "custom-id" + await svc.close() + + 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", + }) + assert session.state["sk"] == "sv" + assert session.state[f"{State.APP_PREFIX}ak"] == "av" + assert session.state[f"{State.USER_PREFIX}uk"] == "uv" + await svc.close() + + async def test_create_with_whitespace_id(self): + svc = _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id=" ") + assert session.id.strip() == session.id + assert len(session.id) > 0 + await svc.close() + + +class TestRedisGetSession: + async def test_get_existing(self): + svc = _create_service() + await svc.create_session(app_name="app", user_id="user", session_id="s1") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is not None + assert result.id == "s1" + await svc.close() + + async def test_get_nonexistent(self): + svc = _create_service() + result = await svc.get_session(app_name="app", user_id="user", session_id="nonexistent") + assert result is None + await svc.close() + + 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", + }) + 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" + assert result.state[f"{State.USER_PREFIX}uk"] == "uv" + await svc.close() + + +class TestRedisListSessions: + async def test_list_empty(self): + svc = _create_service() + result = await svc.list_sessions(app_name="app", user_id="user") + assert result.sessions == [] + await svc.close() + + async def test_list_multiple(self): + svc = _create_service() + await svc.create_session(app_name="app", user_id="user", session_id="s1") + await svc.create_session(app_name="app", user_id="user", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id="user") + assert len(result.sessions) == 2 + await svc.close() + + async def test_list_sessions_have_no_events(self): + svc = _create_service() + created = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event() + await svc.append_event(created, event) + result = await svc.list_sessions(app_name="app", user_id="user") + for s in result.sessions: + assert s.events == [] + assert s.historical_events == [] + 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") + await svc.delete_session(app_name="app", user_id="user", session_id="s1") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is None + await svc.close() + + async def test_delete_nonexistent(self): + svc = _create_service() + await svc.delete_session(app_name="app", user_id="user", session_id="nonexistent") + await svc.close() + + +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") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 1 + await svc.close() + + async def test_append_partial_skipped(self): + svc = _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event(partial=True) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 0 + await svc.close() + + async def test_append_with_state_delta(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", + }) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert stored.state["session_key"] == "sv" + assert stored.state[f"{State.APP_PREFIX}app_key"] == "av" + assert stored.state[f"{State.USER_PREFIX}user_key"] == "uv" + await svc.close() + + 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", + }) + + await svc.append_event(session, event) + + stored_json = svc._redis_storage._store["session:app:user:s1"] + raw_session = Session.model_validate_json(stored_json) + assert raw_session.state == {"session_key": "sv"} + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert stored.state["session_key"] == "sv" + assert stored.state[f"{State.APP_PREFIX}app_key"] == "av" + assert stored.state[f"{State.USER_PREFIX}user_key"] == "uv" + assert f"{State.TEMP_PREFIX}temp_key" not in raw_session.state + await svc.close() + + async def test_append_to_nonexistent_session(self): + svc = _create_service() + session = _make_session_obj(id="nonexistent") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + await svc.close() + + async def test_append_persists_filtered_active_and_historical_events(self): + config = _make_config(max_events=2, store_historical_events=True) + svc = _create_service(config=config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + + for i in range(4): + await svc.append_event(session, _make_event(author="user" if i == 2 else "agent", text=f"msg{i}")) + + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert [event.get_text() for event in stored.events] == ["msg2", "msg3"] + assert [event.get_text() for event in stored.historical_events] == ["msg0", "msg1"] + await svc.close() + + +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") + session.state["new_key"] = "new_val" + await svc.update_session(session) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert stored.state.get("new_key") == "new_val" + await svc.close() + + async def test_update_nonexistent(self): + svc = _create_service() + session = _make_session_obj(id="nonexistent") + await svc.update_session(session) + await svc.close() + + +class TestRedisRefreshTtl: + async def test_refresh_ttl_disabled(self): + svc = _create_service(config=_make_config()) + mock_redis = MagicMock() + await svc._refresh_ttl(mock_redis, "some_key") + await svc.close() + + async def test_refresh_ttl_enabled(self): + config = _make_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=60.0) + svc = _create_service(config=config) + mock_redis = MagicMock() + svc._redis_storage.expire = AsyncMock() + await svc._refresh_ttl(mock_redis, "some_key") + svc._redis_storage.expire.assert_called_once() + await svc.close() + + +class TestRedisClose: + async def test_close(self): + svc = _create_service() + await svc.close() diff --git a/tests/sessions/test_session.py b/tests/sessions/test_session.py new file mode 100644 index 000000000..151fa8297 --- /dev/null +++ b/tests/sessions/test_session.py @@ -0,0 +1,399 @@ +# 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. + +import time + +from google.genai.types import Content +from google.genai.types import Part +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.sessions import is_summary_anchor + + +class TestSession: + """Test suite for Session class.""" + + def test_create_session(self): + """Test creating a new session.""" + session = Session(id="test-session", app_name="test-app", user_id="test-user", save_key="test-key") + + assert session.id == "test-session" + assert session.app_name == "test-app" + assert session.user_id == "test-user" + assert session.events == [] + + def test_add_event(self): + """Test adding events to a session.""" + session = Session(id="test-session", app_name="test-app", user_id="test-user", save_key="test-key") + + event = Event(author="user", content=Content(parts=[Part.from_text(text="Hello")])) + session.add_event(event) + + assert len(session.events) == 1 + assert session.events[0].author == "user" + assert session.last_update_time == event.timestamp + + def test_is_anchor_message(self): + """Test checking if an event can anchor visible conversation history.""" + user_event = Event(author="user", content=Content(parts=[Part.from_text(text="Hello")])) + agent_event = Event(author="agent-1", content=Content(parts=[Part.from_text(text="Hi")])) + summary_event = Event(author="system", content=Content(parts=[Part.from_text(text="Summary")])) + summary_event.set_summary_event(True) + + assert is_summary_anchor(user_event) is True + assert is_summary_anchor(agent_event) is False + assert is_summary_anchor(summary_event) is True + + def test_apply_event_filtering_no_config(self): + """Test event filtering with no configuration.""" + session = Session(id="test-session", app_name="test-app", user_id="test-user", save_key="test-key") + + # Add some events + for i in range(10): + event = Event(author="user" if i % 2 == 0 else "agent", + content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.events.append(event) + + # No filtering parameters provided + session.apply_event_filtering() + + # No filtering should occur + assert len(session.events) == 10 + + def test_apply_event_filtering_max_events(self): + """Test event filtering with max_events limit.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + # Add 10 events (even indices are user messages) + for i in range(10): + event = Event(author="user" if i % 2 == 0 else "agent", + content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.events.append(event) + + # Apply filtering with max_events=5 + session.apply_event_filtering(max_events=5) + + assert len(session.events) == 4 + assert session.events[0].get_text() == "Message 6" + assert session.events[-1].get_text() == "Message 9" + + def test_apply_event_filtering_can_store_filtered_events(self): + """Test filtered events can be moved into historical_events.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + for i in range(6): + event = Event(author="user" if i == 2 else "agent", + content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.events.append(event) + + session.apply_event_filtering(max_events=2, store_filtered_events=True) + + assert [event.get_text() for event in session.events] == ["Message 2"] + assert [event.get_text() for event in session.historical_events] == [ + "Message 0", + "Message 1", + "Message 3", + "Message 4", + "Message 5", + ] + assert all(event.is_model_visible() for event in session.historical_events) + + def test_apply_event_filtering_ttl(self): + """Test event filtering with TTL.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add old user events (3 seconds ago) + for i in range(2): + event = Event(author="user", content=Content(parts=[Part.from_text(text=f"Old user message {i}")])) + event.timestamp = current_time - 3.0 + session.events.append(event) + + # Add old agent event + event = Event(author="agent", content=Content(parts=[Part.from_text(text="Old agent message")])) + event.timestamp = current_time - 3.0 + session.events.append(event) + + # Add recent events (1 second ago) - all agent messages + for i in range(3): + event = Event(author="agent", content=Content(parts=[Part.from_text(text=f"Recent message {i}")])) + event.timestamp = current_time - 1.0 + session.events.append(event) + + # Apply TTL filtering with 2 seconds + session.apply_event_filtering(event_ttl_seconds=2.0) + + # TTL + user-anchor fallback keeps only the last user message. + assert len(session.events) == 1 + assert session.events[0].author == "user" + assert "Old user message 1" in session.events[0].get_text() + + def test_apply_event_filtering_ttl_and_max_events(self): + """Test event filtering with both TTL and max_events.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add 10 old user events (6 seconds ago) - should be filtered by TTL + for i in range(10): + event = Event(author="user", content=Content(parts=[Part.from_text(text=f"Very old {i}")])) + event.timestamp = current_time - 6.0 + session.events.append(event) + + # Add 10 recent events (1 second ago) - should pass TTL but be limited by max_events + # Mix user and agent messages + for i in range(10): + author = "user" if i % 3 == 0 else "agent" + event = Event(author=author, content=Content(parts=[Part.from_text(text=f"Recent {i}")])) + event.timestamp = current_time - 1.0 + session.events.append(event) + + # Apply both filters + session.apply_event_filtering(event_ttl_seconds=5.0, max_events=5) + + assert len(session.events) == 4 + assert session.events[0].get_text() == "Recent 6" + assert session.events[-1].get_text() == "Recent 9" + + def test_apply_event_filtering_preserves_last_user_message(self): + """Test that filtering preserves the last user message when all events are filtered.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add old events + user_event = Event(author="user", content=Content(parts=[Part.from_text(text="User question")])) + user_event.timestamp = current_time - 10.0 + session.events.append(user_event) + + for i in range(5): + agent_event = Event(author="agent", content=Content(parts=[Part.from_text(text=f"Agent response {i}")])) + agent_event.timestamp = current_time - 10.0 + session.events.append(agent_event) + + # Another user event + last_user_event = Event(author="user", content=Content(parts=[Part.from_text(text="Last user message")])) + last_user_event.timestamp = current_time - 10.0 + session.events.append(last_user_event) + + # Apply strict TTL filter that would remove all events + session.apply_event_filtering(event_ttl_seconds=2.0) + + # All events are old, but last user message remains as the anchor. + assert len(session.events) == 1 + assert session.events[0].author == "user" + assert session.events[0].get_text() == "Last user message" + + def test_apply_event_filtering_empty_events(self): + """Test event filtering with no events.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + session.apply_event_filtering(event_ttl_seconds=2.0, max_events=5) + + assert session.events == [] + + def test_apply_event_filtering_all_filtered_no_user_message(self): + """Test filtering when all events are filtered and there's no user message.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add only agent events (old) + for i in range(5): + agent_event = Event(author="agent", content=Content(parts=[Part.from_text(text=f"Agent message {i}")])) + agent_event.timestamp = current_time - 10.0 + session.events.append(agent_event) + + # Apply strict TTL filter + session.apply_event_filtering(event_ttl_seconds=2.0) + + assert session.events == [] + + def test_apply_event_filtering_case_insensitive_user(self): + """Test that user detection is case-insensitive.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add events with different case for "user" + for author in ["USER", "User", "user", "uSeR"]: + event = Event(author=author, content=Content(parts=[Part.from_text(text=f"Message from {author}")])) + event.timestamp = current_time - 10.0 + session.events.append(event) + + # Add agent event + agent_event = Event(author="agent", content=Content(parts=[Part.from_text(text="Agent message")])) + agent_event.timestamp = current_time - 10.0 + session.events.append(agent_event) + + # Apply strict TTL filter + session.apply_event_filtering(event_ttl_seconds=2.0) + + # Last user message is preserved as the anchor (case-insensitive). + assert len(session.events) == 1 + assert session.events[0].author.lower() == "user" + assert session.events[0].get_text() == "Message from uSeR" + + def test_apply_event_filtering_max_events_less_than_one(self): + """Test that max_events <= 0 is treated as no limit.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + # Add events + for i in range(10): + event = Event(author="user", content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.events.append(event) + + # Apply filtering with max_events=0 (no limit) + session.apply_event_filtering(max_events=0) + + # Should keep all events + assert len(session.events) == 10 + + def test_apply_event_filtering_ttl_less_than_zero(self): + """Test that TTL <= 0 is treated as no TTL.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add very old events + for i in range(5): + event = Event(author="user", content=Content(parts=[Part.from_text(text=f"Old message {i}")])) + event.timestamp = current_time - 100.0 + session.events.append(event) + + # Apply filtering with event_ttl_seconds=0 (no TTL) + session.apply_event_filtering(event_ttl_seconds=0) + + # Should keep all events + assert len(session.events) == 5 + + def test_add_event_with_filtering(self): + """Test add_event with filtering parameters.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + # Add 10 events with max_events=5 (even indices are user messages) + for i in range(10): + event = Event(author="user" if i % 2 == 0 else "agent", + content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.add_event(event, max_events=5) + + assert len(session.events) == 4 + assert session.events[0].get_text() == "Message 6" + assert session.events[-1].get_text() == "Message 9" + + def test_add_event_with_filtering_can_store_filtered_events(self): + """Test add_event moves filtered events to historical_events when requested.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + for i in range(6): + event = Event(author="user" if i == 2 else "agent", + content=Content(parts=[Part.from_text(text=f"Message {i}")])) + session.add_event(event, max_events=2, store_filtered_events=True) + + assert [event.get_text() for event in session.events] == ["Message 2", "Message 5"] + assert [event.get_text() for event in session.historical_events] == [ + "Message 0", + "Message 1", + "Message 3", + "Message 4", + ] + + def test_apply_event_filtering_keeps_first_user_message_and_after(self): + """Test that filtering keeps the first user message and all events after it.""" + session = Session( + id="test-session", + app_name="test-app", + user_id="test-user", + save_key="test-key", + ) + + current_time = time.time() + + # Add agent events before user message + for i in range(3): + event = Event(author="agent", content=Content(parts=[Part.from_text(text=f"Agent {i}")])) + event.timestamp = current_time - 1.0 + session.events.append(event) + + # Add user message + user_event = Event(author="user", content=Content(parts=[Part.from_text(text="User question")])) + user_event.timestamp = current_time - 1.0 + session.events.append(user_event) + + # Add more agent events after user message + for i in range(3): + event = Event(author="agent", content=Content(parts=[Part.from_text(text=f"Agent response {i}")])) + event.timestamp = current_time - 1.0 + session.events.append(event) + + # Apply max_events filter with small limit + session.apply_event_filtering(max_events=3) + + # When the retained tail has no user message, fallback keeps the last user message only. + assert len(session.events) == 1 + assert session.events[0].author == "user" + assert session.events[0].get_text() == "User question" diff --git a/tests/sessions/test_session_summarizer.py b/tests/sessions/test_session_summarizer.py new file mode 100644 index 000000000..ef46db4bb --- /dev/null +++ b/tests/sessions/test_session_summarizer.py @@ -0,0 +1,704 @@ +# 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 trpc_agent_sdk.sessions._session_summarizer. + +Covers: +- SessionSummary: get_compression_ratio, to_dict +- SessionSummarizer: should_summarize, _has_important_content, + _extract_conversation_text, _create_summarization_prompt, + create_session_summary, create_session_summary_by_events, get_summary_metadata +""" + +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._session_summarizer import ( + DEFAULT_SUMMARIZER_PROMPT, + SessionSummarizer, + SessionSummary, +) +from trpc_agent_sdk.types import Content, EventActions, FunctionCall, FunctionResponse, Part + +_DEFAULT_ACTIONS = EventActions() + + +def _make_session(events=None) -> Session: + s = Session(id="s1", app_name="app", user_id="user", save_key="app/user") + s.events = events or [] + return s + + +def _make_event(author="agent", text="hello", partial=False, branch=None, skip_summarization=False) -> Event: + actions = EventActions(skip_summarization=True) if skip_summarization else EventActions() + return Event( + invocation_id="inv-1", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + partial=partial, + branch=branch, + actions=actions, + ) + + +def _make_model_mock(): + model = MagicMock() + model.name = "test-model" + return model + + +# --------------------------------------------------------------------------- +# SessionSummary +# --------------------------------------------------------------------------- + +class TestSessionSummary: + def test_get_compression_ratio(self): + summary = SessionSummary( + session_id="s1", + summary_text="summary", + original_event_count=100, + compressed_event_count=10, + summary_timestamp=time.time(), + ) + assert summary.get_compression_ratio() == 90.0 + + def test_get_compression_ratio_zero_original(self): + summary = SessionSummary( + session_id="s1", + summary_text="summary", + original_event_count=0, + compressed_event_count=0, + summary_timestamp=time.time(), + ) + assert summary.get_compression_ratio() == 0.0 + + def test_get_compression_ratio_no_compression(self): + summary = SessionSummary( + session_id="s1", + summary_text="summary", + original_event_count=10, + compressed_event_count=10, + summary_timestamp=time.time(), + ) + assert summary.get_compression_ratio() == 0.0 + + +# --------------------------------------------------------------------------- +# SessionSummarizer — should_summarize +# --------------------------------------------------------------------------- + +class TestShouldSummarize: + async def test_empty_events(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + session = _make_session(events=[]) + assert await summarizer.should_summarize(session) is False + + async def test_default_checker_below_threshold(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + session = _make_session(events=[_make_event()]) + session.conversation_count = 5 + assert await summarizer.should_summarize(session) is False + + async def test_custom_checker_passes(self): + model = _make_model_mock() + checker = lambda s: True + summarizer = SessionSummarizer(model=model, check_summarizer_functions=[checker]) + session = _make_session(events=[_make_event()]) + assert await summarizer.should_summarize(session) is True + + async def test_custom_checker_fails(self): + model = _make_model_mock() + checker = lambda s: False + summarizer = SessionSummarizer(model=model, check_summarizer_functions=[checker]) + session = _make_session(events=[_make_event()]) + assert await summarizer.should_summarize(session) is False + + async def test_multiple_checkers_all_must_pass(self): + model = _make_model_mock() + c1 = lambda s: True + c2 = lambda s: False + summarizer = SessionSummarizer(model=model, check_summarizer_functions=[c1, c2]) + session = _make_session(events=[_make_event()]) + assert await summarizer.should_summarize(session) is False + + +# --------------------------------------------------------------------------- +# SessionSummarizer — _has_important_content +# --------------------------------------------------------------------------- + +class TestHasImportantContent: + def test_no_events(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + assert summarizer._has_important_content([]) is False + + def test_event_with_long_text(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [_make_event(text="This is a meaningful conversation")] + assert summarizer._has_important_content(events) is True + + def test_event_with_short_text(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [_make_event(text="hi")] + assert summarizer._has_important_content(events) is False + + def test_event_without_content(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + event = Event(invocation_id="inv-1", author="agent", actions=_DEFAULT_ACTIONS) + assert summarizer._has_important_content([event]) is False + + def test_event_with_empty_parts(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + event = Event(invocation_id="inv-1", author="agent", content=Content(parts=[]), actions=_DEFAULT_ACTIONS) + assert summarizer._has_important_content([event]) is False + + +# --------------------------------------------------------------------------- +# SessionSummarizer — _extract_conversation_text +# --------------------------------------------------------------------------- + +class TestExtractConversationText: + def test_basic_extraction(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [ + _make_event(author="user", text="What is AI?"), + _make_event(author="agent", text="AI is artificial intelligence."), + ] + text = summarizer._extract_conversation_text(events) + assert "user: What is AI?" in text + assert "agent: AI is artificial intelligence." in text + + def test_skip_summarization_events_are_still_included(self): + # ``skip_summarization=True`` means "the agent loop should not call + # the LLM again to summarize this tool response" (a control-flow + # concern). It must NOT cause the *session* summarizer to drop the + # event from the summary input, because these events usually carry + # the actual user-visible final answer (e.g. AgentTool / + # StreamingProgressTool outputs). Dropping them would strip the + # most informative content from the resulting session summary. + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [ + _make_event(author="user", text="Question"), + _make_event(author="agent", text="FinalAnswerFromSubAgent", skip_summarization=True), + _make_event(author="agent", text="Included"), + ] + text = summarizer._extract_conversation_text(events) + assert "FinalAnswerFromSubAgent" in text + assert "Included" in text + + def test_empty_events(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + text = summarizer._extract_conversation_text([]) + assert text == "" + + def test_event_without_content(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + event = Event(invocation_id="inv-1", author="agent", actions=_DEFAULT_ACTIONS) + text = summarizer._extract_conversation_text([event]) + assert text == "" + + def test_function_call_extraction(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + fc = FunctionCall(name="search", args={"query": "test"}) + event = Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part(function_call=fc)]), + actions=_DEFAULT_ACTIONS, + ) + text = summarizer._extract_conversation_text([event]) + assert "tool_call" in text + assert "search" in text + + def test_function_response_extraction(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + fr = FunctionResponse(name="search", response={"result": "found"}) + event = Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part(function_response=fr)]), + actions=_DEFAULT_ACTIONS, + ) + text = summarizer._extract_conversation_text([event]) + assert "tool_response" in text + assert "search" in text + + def test_partial_events_merged(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [ + _make_event(author="agent", text="part1", partial=True, branch="main"), + _make_event(author="agent", text="part2", partial=True, branch="main"), + ] + text = summarizer._extract_conversation_text(events) + assert "part1" in text + assert "part2" in text + + def test_whitespace_only_text_skipped(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=" ")] + text = summarizer._extract_conversation_text(events) + assert text == "" + + +# --------------------------------------------------------------------------- +# SessionSummarizer — _create_summarization_prompt +# --------------------------------------------------------------------------- + +class TestCreateSummarizationPrompt: + def test_default_prompt(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + prompt = summarizer._create_summarization_prompt("Hello conversation") + assert "Hello conversation" in prompt + assert "Summary:" in prompt + + def test_custom_prompt(self): + model = _make_model_mock() + custom = "Summarize: {conversation_text}" + summarizer = SessionSummarizer(model=model, summarizer_prompt=custom) + prompt = summarizer._create_summarization_prompt("test content") + assert prompt == "Summarize: test content" + + +# --------------------------------------------------------------------------- +# SessionSummarizer — _generate_summary +# --------------------------------------------------------------------------- + +class TestGenerateSummary: + async def test_generate_summary_success(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="This is the summary.")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + result = await summarizer._generate_summary("conversation text") + assert result == "This is the summary." + + async def test_generate_summary_truncated(self): + model = _make_model_mock() + long_text = "A" * 2000 + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text=long_text)]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, max_summary_length=100) + result = await summarizer._generate_summary("conversation text") + assert len(result) <= 104 # 100 + "..." + assert result.endswith("...") + + async def test_generate_summary_error(self): + model = _make_model_mock() + + async def mock_generate(request, stream=False, ctx=None): + raise RuntimeError("LLM error") + yield # pragma: no cover + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + result = await summarizer._generate_summary("text") + assert result == "" + + +# --------------------------------------------------------------------------- +# SessionSummarizer — create_session_summary_by_events +# --------------------------------------------------------------------------- + +class TestCreateSessionSummaryByEvents: + async def test_summary_with_keep_recent(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=f"msg{i}") for i in range(10)] + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=3) + assert summary_text is not None + assert len(result_events) == 4 # 1 summary + 3 recent + assert any(event.is_summary_event() for event in result_events) + summary_event = next(event for event in result_events if event.is_summary_event()) + assert summary_event.author == "system" + assert summary_event.content.role == "user" + + async def test_summary_starts_from_first_user_turn_before_recent_events(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, start_by_user_turn=True) + old_user = _make_event(author="user", text="old question") + old_answer = _make_event(author="agent", text="old answer") + recent_user = _make_event(author="user", text="recent question") + system_preamble = _make_event(author="system", text="system preamble") + events = [ + system_preamble, + old_user, + old_answer, + recent_user, + ] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=1) + + assert summary_text == "summary text" + assert result_events is events + assert captured_prompts + assert "old question" in captured_prompts[0] + assert "old answer" in captured_prompts[0] + assert "system preamble" not in captured_prompts[0] + assert "recent question" not in captured_prompts[0] + assert result_events[0].is_summary_event() + assert result_events[1] is recent_user + + async def test_summary_can_start_from_existing_summary_event(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, start_by_user_turn=True) + existing_summary = _make_event(author="system", text="previous summary") + existing_summary.set_summary_event(True) + system_preamble = _make_event(author="system", text="system preamble") + events = [ + system_preamble, + existing_summary, + _make_event(author="agent", text="old answer"), + _make_event(author="user", text="recent question"), + ] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=1) + + assert summary_text == "summary text" + assert "previous summary" in captured_prompts[0] + assert "old answer" in captured_prompts[0] + assert "system preamble" not in captured_prompts[0] + assert result_events[0].is_summary_event() + + async def test_summary_falls_back_to_first_visible_event_and_ignores_large_keep_recent(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, start_by_user_turn=True) + events = [ + _make_event(author="agent", text="agent message 1"), + _make_event(author="agent", text="agent message 2"), + ] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=10) + + assert summary_text == "summary text" + assert "agent message 1" in captured_prompts[0] + assert "agent message 2" in captured_prompts[0] + assert len(result_events) == 1 + assert result_events[0].is_summary_event() + + async def test_summary_inserted_before_recent_user_turn_and_hides_prior_events(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, start_by_user_turn=True) + events = [_make_event(author="user" if idx in (8, 80, 92) else "agent", text=f"msg {idx}") for idx in range(100)] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=10) + + assert summary_text == "summary text" + assert "msg 8" in captured_prompts[0] + assert "msg 91" in captured_prompts[0] + assert "msg 92" not in captured_prompts[0] + assert result_events[0].is_summary_event() + assert result_events[1].content.parts[0].text == "msg 92" + + async def test_summary_with_zero_keep_recent(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=f"msg{i}") for i in range(5)] + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=0) + assert summary_text is not None + assert len(result_events) == 1 # only summary event remains active + assert result_events[0].is_summary_event() + assert result_events[0].content.role == "user" + + async def test_summary_can_store_historical_events_separately(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="summary text")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=f"msg{i}") for i in range(5)] + historical_events = [] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=2, historical_events=historical_events, store_historical_events=True) + + assert summary_text == "summary text" + assert len(result_events) == 3 + assert len(historical_events) == 3 + assert historical_events[0].content.parts[0].text == "msg0" + + async def test_resummary_compresses_existing_summary_anchor(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="new summary")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, start_by_user_turn=True) + previous_summary = _make_event(author="system", text="Previous conversation summary: old summary") + previous_summary.set_summary_event(True) + events = [previous_summary] + [_make_event(text=f"msg{i}") for i in range(100, 181)] + historical_events = [] + + summary_text, result_events = await summarizer.create_session_summary_by_events( + events, "s1", keep_recent_count=20, historical_events=historical_events, store_historical_events=True) + + assert summary_text == "new summary" + assert result_events[0].is_summary_event() + assert result_events[0] is not previous_summary + assert [event.get_text() for event in result_events[1:]] == [f"msg{i}" for i in range(161, 181)] + assert previous_summary in historical_events + assert "old summary" in captured_prompts[0] + assert "msg160" in captured_prompts[0] + assert "msg161" not in captured_prompts[0] + + async def test_summary_no_events(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + summary_text, result_events = await summarizer.create_session_summary_by_events([], "s1") + assert summary_text is None + assert result_events == [] + + async def test_summary_error_returns_none(self): + model = _make_model_mock() + + async def mock_generate(request, stream=False, ctx=None): + raise RuntimeError("error") + yield # pragma: no cover + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text=f"msg{i}") for i in range(5)] + summary_text, result_events = await summarizer.create_session_summary_by_events(events, "s1") + assert summary_text is None + + +# --------------------------------------------------------------------------- +# SessionSummarizer — create_session_summary +# --------------------------------------------------------------------------- + +class TestCreateSessionSummary: + async def test_summary_updates_session_events(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="session summary")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, keep_recent_count=2) + session = _make_session(events=[_make_event(text=f"msg{i}") for i in range(10)]) + result = await summarizer.create_session_summary(session) + assert result is not None + assert len(session.events) == 3 # 1 summary + 2 recent + assert any(event.is_summary_event() for event in session.events) + + async def test_summary_starts_from_first_user_turn_in_session(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="session summary")]) + captured_prompts = [] + + async def mock_generate(request, stream=False, ctx=None): + captured_prompts.append(request.contents[0].parts[0].text) + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, keep_recent_count=1, start_by_user_turn=True) + old_user = _make_event(author="user", text="old question") + old_answer = _make_event(author="agent", text="old answer") + recent_user = _make_event(author="user", text="recent question") + system_preamble = _make_event(author="system", text="system preamble") + session = _make_session(events=[ + system_preamble, + old_user, + old_answer, + recent_user, + ]) + + result = await summarizer.create_session_summary(session) + + assert result == "session summary" + assert captured_prompts + assert "old question" in captured_prompts[0] + assert "old answer" in captured_prompts[0] + assert "system preamble" not in captured_prompts[0] + assert "recent question" not in captured_prompts[0] + assert session.events[0].is_summary_event() + assert session.events[1] is recent_user + + async def test_summary_without_visible_user_falls_back_to_first_visible_event(self): + model = _make_model_mock() + llm_response = MagicMock() + llm_response.content = Content(parts=[Part.from_text(text="session summary")]) + + async def mock_generate(request, stream=False, ctx=None): + yield llm_response + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, keep_recent_count=10, start_by_user_turn=True) + events = [ + _make_event(author="system", text="system preamble"), + _make_event(author="agent", text="agent answer"), + ] + session = _make_session(events=events) + + result = await summarizer.create_session_summary(session) + + assert result == "session summary" + assert len(session.events) == 1 + assert session.events[0].is_summary_event() + + async def test_summary_no_update_on_failure(self): + model = _make_model_mock() + + async def mock_generate(request, stream=False, ctx=None): + raise RuntimeError("fail") + yield # pragma: no cover + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model, keep_recent_count=2) + events = [_make_event(text=f"msg{i}") for i in range(10)] + session = _make_session(events=events) + result = await summarizer.create_session_summary(session) + assert result is None + assert len(session.events) == 10 + + +# --------------------------------------------------------------------------- +# SessionSummarizer — get_summary_metadata +# --------------------------------------------------------------------------- + +class TestGetSummaryMetadata: + def test_metadata(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model, max_summary_length=500, keep_recent_count=5) + metadata = summarizer.get_summary_metadata() + assert metadata["model_name"] == "test-model" + assert metadata["max_summary_length"] == 500 + assert metadata["keep_recent_count"] == 5 + assert metadata["model_available"] is True + + +# --------------------------------------------------------------------------- +# SessionSummarizer — _compress_session_to_summary +# --------------------------------------------------------------------------- + +class TestCompressSessionToSummary: + async def test_no_events(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + result = await summarizer._compress_session_to_summary([], "s1") + assert result is None + + async def test_no_model(self): + summarizer = SessionSummarizer(model=None) + result = await summarizer._compress_session_to_summary([_make_event()], "s1") + assert result is None + + async def test_no_conversation_text(self): + model = _make_model_mock() + summarizer = SessionSummarizer(model=model) + event = Event(invocation_id="inv-1", author="agent", actions=_DEFAULT_ACTIONS) + result = await summarizer._compress_session_to_summary([event], "s1") + assert result is None + + async def test_exception_handling(self): + model = _make_model_mock() + + async def mock_generate(request, stream=False, ctx=None): + raise RuntimeError("LLM error") + yield # pragma: no cover + + model.generate_async = mock_generate + summarizer = SessionSummarizer(model=model) + events = [_make_event(text="meaningful content here")] + result = await summarizer._compress_session_to_summary(events, "s1") + assert result is None or result == "" diff --git a/tests/sessions/test_sql_session_service.py b/tests/sessions/test_sql_session_service.py new file mode 100644 index 000000000..30b3df1e5 --- /dev/null +++ b/tests/sessions/test_sql_session_service.py @@ -0,0 +1,458 @@ +# 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 trpc_agent_sdk.sessions._sql_session_service. + +Covers: +- StorageSession: to_session +- SessionStorageEvent: from_event, to_event, long_running_tool_ids property +- SqlSessionService: create_session, get_session, list_sessions, delete_session, + append_event, update_session, state management, cleanup, close +Uses in-memory SQLite for integration-style tests. +""" + +from __future__ import annotations + +import asyncio +import time +from datetime import datetime, timezone +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._sql_session_service import ( + SessionStorageEvent, + SqlSessionService, + StorageSession, +) +from trpc_agent_sdk.sessions._types import SessionServiceConfig +from trpc_agent_sdk.types import Content, EventActions, FunctionCall, Part, State + + +def _make_config(ttl_seconds=0, + cleanup_interval=0.0, + enable_ttl=False, + num_recent_events=0, + max_events=0, + store_historical_events=False): + config = SessionServiceConfig(num_recent_events=num_recent_events, + 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) + else: + config.clean_ttl_config() + return config + + +def _make_event(author="agent", text="hello", state_delta=None, partial=False): + actions = EventActions(state_delta=state_delta) if state_delta else EventActions() + return Event( + invocation_id="inv-1", + author=author, + content=Content(parts=[Part.from_text(text=text)]), + actions=actions, + partial=partial, + ) + + +def _make_event_with_function_call(): + fc = FunctionCall(name="tool", args={"key": "val"}) + return Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part(function_call=fc)]), + ) + + +async def _create_service(config=None): + config = config or _make_config() + svc = SqlSessionService(db_url="sqlite:///:memory:", session_config=config, is_async=False) + await svc._sql_storage.create_sql_engine() + return svc + + +# --------------------------------------------------------------------------- +# 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") + storage_event = SessionStorageEvent.from_event(session, event) + assert storage_event.id == event.id + assert storage_event.author == "user" + assert storage_event.app_name == "app" + assert storage_event.session_id == "s1" + + def test_from_event_with_function_call(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = _make_event_with_function_call() + storage_event = SessionStorageEvent.from_event(session, event) + assert storage_event.content is not None + + def test_from_event_drops_empty_parts(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part()]), + ) + storage_event = SessionStorageEvent.from_event(session, event) + assert storage_event.content is None + + def test_from_event_no_content(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = Event(invocation_id="inv-1", author="agent", actions=EventActions()) + storage_event = SessionStorageEvent.from_event(session, event) + assert storage_event.content is None + + def test_to_event_drops_legacy_empty_parts(self): + storage_event = SessionStorageEvent( + id="e1", + app_name="app", + user_id="user", + session_id="s1", + invocation_id="inv-1", + author="agent", + actions=EventActions(), + long_running_tool_ids=set(), + timestamp=datetime.now(), + model_flags=1, + content={"parts": [{}], "role": "model"}, + ) + event = storage_event.to_event() + assert event.content is None + + def test_long_running_tool_ids_property(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = _make_event() + event.long_running_tool_ids = {"tool-1", "tool-2"} + storage_event = SessionStorageEvent.from_event(session, event) + assert storage_event.long_running_tool_ids == {"tool-1", "tool-2"} + + def test_long_running_tool_ids_none(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = _make_event() + storage_event = SessionStorageEvent.from_event(session, event) + storage_event.long_running_tool_ids = None + assert storage_event.long_running_tool_ids_json is None + + def test_long_running_tool_ids_empty(self): + session = Session(id="s1", app_name="app", user_id="user", save_key="k") + event = _make_event() + event.long_running_tool_ids = set() + storage_event = SessionStorageEvent.from_event(session, event) + ids = storage_event.long_running_tool_ids + assert ids == set() + + +# --------------------------------------------------------------------------- +# 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") + assert session.app_name == "app" + assert session.user_id == "user" + assert session.id is not None + await svc.close() + + async def test_create_with_custom_id(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="custom-id") + assert session.id == "custom-id" + await svc.close() + + 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", + }) + assert session.state["sk"] == "sv" + assert session.state[f"{State.APP_PREFIX}ak"] == "av" + assert session.state[f"{State.USER_PREFIX}uk"] == "uv" + await svc.close() + + async def test_create_existing_session_updates(self): + svc = await _create_service() + s1 = await svc.create_session(app_name="app", user_id="user", session_id="s1", state={"k1": "v1"}) + s2 = await svc.create_session(app_name="app", user_id="user", session_id="s1", state={"k2": "v2"}) + assert s2.state.get("k2") == "v2" + await svc.close() + + async def test_create_with_whitespace_id(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id=" ") + assert len(session.id) > 0 + assert session.id.strip() == session.id + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is not None + assert result.id == "s1" + await svc.close() + + async def test_get_nonexistent(self): + svc = await _create_service() + result = await svc.get_session(app_name="app", user_id="user", session_id="nonexistent") + assert result is None + await svc.close() + + async def test_get_with_events(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event(text="test event") + await svc.append_event(session, event) + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(result.events) == 1 + await svc.close() + + async def test_get_with_num_recent_events(self): + config = _make_config(num_recent_events=2) + svc = await _create_service(config=config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + for i in range(5): + event = _make_event(text=f"msg{i}") + await svc.append_event(session, event) + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(result.events) <= 2 + await svc.close() + + 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", + }) + 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" + assert result.state[f"{State.USER_PREFIX}uk"] == "uv" + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + assert result.sessions == [] + await svc.close() + + async def test_list_multiple(self): + svc = await _create_service() + await svc.create_session(app_name="app", user_id="user", session_id="s1") + await svc.create_session(app_name="app", user_id="user", session_id="s2") + result = await svc.list_sessions(app_name="app", user_id="user") + assert len(result.sessions) == 2 + await svc.close() + + async def test_list_sessions_have_no_events(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event() + await svc.append_event(session, event) + result = await svc.list_sessions(app_name="app", user_id="user") + for s in result.sessions: + assert s.events == [] + assert s.historical_events == [] + 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") + await svc.delete_session(app_name="app", user_id="user", session_id="s1") + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is None + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 1 + await svc.close() + + async def test_append_partial_skipped(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + event = _make_event(partial=True) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 0 + await svc.close() + + async def test_append_with_state_delta(self): + svc = await _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", + }) + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert stored.state["session_key"] == "sv" + assert stored.state[f"{State.APP_PREFIX}app_key"] == "av" + assert stored.state[f"{State.USER_PREFIX}user_key"] == "uv" + await svc.close() + + async def test_append_to_nonexistent_session(self): + svc = await _create_service() + session = Session(id="nonexistent", app_name="app", user_id="user", save_key="k") + event = _make_event() + result = await svc.append_event(session, event) + assert result is event + await svc.close() + + async def test_append_multiple_events(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + for i in range(5): + event = _make_event(text=f"msg{i}") + await svc.append_event(session, event) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 5 + await svc.close() + + async def test_append_without_filtering_does_not_delete_event_rows(self): + svc = await _create_service() + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + delete_mock = AsyncMock(wraps=svc._sql_storage.delete) + svc._sql_storage.delete = delete_mock + + await svc.append_event(session, _make_event(text="msg0")) + + delete_mock.assert_not_awaited() + await svc.close() + + async def test_append_persists_historical_events(self): + config = _make_config(max_events=2, store_historical_events=True) + svc = await _create_service(config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + + for i in range(4): + await svc.append_event(session, _make_event(author="user" if i == 2 else "agent", text=f"msg{i}")) + + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert [event.get_text() for event in stored.events] == ["msg2", "msg3"] + assert [event.get_text() for event in stored.historical_events] == ["msg0", "msg1"] + await svc.close() + + +# --------------------------------------------------------------------------- +# 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") + event = _make_event(text="original event") + await svc.append_event(session, event) + session.events = [] + await svc.update_session(session) + stored = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert len(stored.events) == 0 + await svc.close() + + async def test_update_nonexistent(self): + svc = await _create_service() + session = Session(id="nonexistent", app_name="app", user_id="user", save_key="k") + await svc.update_session(session) + await svc.close() + + +# --------------------------------------------------------------------------- +# 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"): + svc = SqlSessionService(db_url="sqlite:///:memory:", session_config=config) + assert svc._SqlSessionService__cleanup_task is None + + async def test_cleanup_task_created(self): + config = _make_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = await _create_service(config=config) + assert svc._SqlSessionService__cleanup_task is not None + await svc.close() + + async def test_stop_cleanup(self): + config = _make_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = await _create_service(config=config) + svc._stop_cleanup_task() + assert svc._SqlSessionService__cleanup_task is None + await svc.close() + + async def test_stop_cleanup_when_no_task(self): + svc = await _create_service() + svc._stop_cleanup_task() + await svc.close() + + async def test_close_stops_cleanup(self): + config = _make_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=3600.0) + svc = await _create_service(config=config) + await svc.close() + assert svc._SqlSessionService__cleanup_task is None + + async def test_cleanup_expired_async(self): + config = _make_config(enable_ttl=True, ttl_seconds=1, cleanup_interval=3600.0) + svc = await _create_service(config=config) + session = await svc.create_session(app_name="app", user_id="user", session_id="s1") + await asyncio.sleep(2) + await svc._cleanup_expired_async() + result = await svc.get_session(app_name="app", user_id="user", session_id="s1") + assert result is None + await svc.close() + + async def test_cleanup_loop_runs_and_stops(self): + config = _make_config(enable_ttl=True, ttl_seconds=3600, cleanup_interval=0.05) + svc = await _create_service(config=config) + await asyncio.sleep(0.15) + await svc.close() diff --git a/tests/sessions/test_summarizer_checker.py b/tests/sessions/test_summarizer_checker.py new file mode 100644 index 000000000..62613ed40 --- /dev/null +++ b/tests/sessions/test_summarizer_checker.py @@ -0,0 +1,369 @@ +# 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 trpc_agent_sdk.sessions._summarizer_checker. + +Covers: +- set_summarizer_token_threshold +- set_summarizer_events_count_threshold +- set_summarizer_time_interval_threshold +- set_summarizer_important_content_threshold +- set_summarizer_conversation_threshold +- set_summarizer_check_functions_by_and +- set_summarizer_check_functions_by_or +""" + +from __future__ import annotations + +import time +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._summarizer_checker import ( + set_summarizer_check_functions_by_and, + set_summarizer_check_functions_by_or, + set_summarizer_conversation_threshold, + set_summarizer_events_count_threshold, + set_summarizer_important_content_threshold, + set_summarizer_time_interval_threshold, + set_summarizer_token_threshold, +) +from trpc_agent_sdk.types import Content, EventActions, Part + + +def _make_session(events=None, conversation_count=0) -> Session: + s = Session(id="s1", app_name="app", user_id="user", save_key="app/user") + s.events = events or [] + s.conversation_count = conversation_count + return s + + +def _make_event_with_usage(total_tokens: int) -> Event: + mock_usage = MagicMock() + mock_usage.total_token_count = total_tokens + event = Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part.from_text(text="test")]), + ) + event.usage_metadata = mock_usage + return event + + +def _make_summary_event() -> Event: + event = _make_event_with_text("Previous conversation summary") + event.set_summary_event(True) + event.set_model_visible(True) + return event + + +def _make_event_with_text(text: str) -> Event: + return Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part.from_text(text=text)]), + ) + + +class TestTokenThreshold: + """Test set_summarizer_token_threshold.""" + + def test_above_threshold(self): + checker = set_summarizer_token_threshold(100) + events = [_make_event_with_usage(60), _make_event_with_usage(50)] + session = _make_session(events=events) + assert checker(session) is True + + def test_below_threshold(self): + checker = set_summarizer_token_threshold(100) + events = [_make_event_with_usage(30), _make_event_with_usage(20)] + session = _make_session(events=events) + assert checker(session) is False + + def test_exact_threshold(self): + checker = set_summarizer_token_threshold(100) + events = [_make_event_with_usage(50), _make_event_with_usage(50)] + session = _make_session(events=events) + assert checker(session) is False + + def test_no_usage_metadata(self): + checker = set_summarizer_token_threshold(100) + events = [_make_event_with_text("hello")] + session = _make_session(events=events) + assert checker(session) is False + + def test_ignores_leading_summary_anchor_tokens(self): + checker = set_summarizer_token_threshold(100) + summary_event = _make_summary_event() + summary_event.usage_metadata = MagicMock() + summary_event.usage_metadata.total_token_count = 1000 + new_event = _make_event_with_usage(10) + summary_event.timestamp = 2.0 + new_event.timestamp = 3.0 + session = _make_session(events=[summary_event, new_event]) + + assert checker(session) is False + + def test_counts_flagged_invisible_tokens_after_latest_summary(self): + checker = set_summarizer_token_threshold(100) + summary_event = _make_summary_event() + flagged_event = _make_event_with_usage(1000) + summary_event.timestamp = 1.0 + flagged_event.timestamp = 2.0 + flagged_event.set_model_visible(False) + session = _make_session(events=[summary_event, flagged_event]) + + assert checker(session) is True + + +class TestEventsCountThreshold: + """Test set_summarizer_events_count_threshold.""" + + def test_above_threshold(self): + checker = set_summarizer_events_count_threshold(5) + events = [_make_event_with_text(f"msg{i}") for i in range(6)] + session = _make_session(events=events) + assert checker(session) is True + + def test_below_threshold(self): + checker = set_summarizer_events_count_threshold(5) + events = [_make_event_with_text(f"msg{i}") for i in range(3)] + session = _make_session(events=events) + assert checker(session) is False + + def test_exact_threshold(self): + checker = set_summarizer_events_count_threshold(5) + events = [_make_event_with_text(f"msg{i}") for i in range(5)] + session = _make_session(events=events) + assert checker(session) is False + + def test_default_threshold(self): + checker = set_summarizer_events_count_threshold() + events = [_make_event_with_text(f"msg{i}") for i in range(31)] + session = _make_session(events=events) + assert checker(session) is True + + def test_counts_events_after_leading_summary_anchor(self): + checker = set_summarizer_events_count_threshold(2) + summary_event = _make_summary_event() + new_events = [_make_event_with_text("new1"), _make_event_with_text("new2")] + summary_event.timestamp = 20.0 + new_events[0].timestamp = 21.0 + new_events[1].timestamp = 22.0 + session = _make_session(events=[summary_event, *new_events]) + + assert checker(session) is False + + def test_non_leading_summary_is_counted_as_active_event(self): + checker = set_summarizer_events_count_threshold(1) + event_after_first_summary = _make_event_with_text("already summarized") + non_leading_summary = _make_summary_event() + new_event = _make_event_with_text("new") + event_after_first_summary.timestamp = 2.0 + non_leading_summary.timestamp = 3.0 + new_event.timestamp = 4.0 + session = _make_session(events=[event_after_first_summary, non_leading_summary, new_event]) + + assert checker(session) is True + + +class TestTimeIntervalThreshold: + """Test set_summarizer_time_interval_threshold.""" + + def test_above_threshold(self): + checker = set_summarizer_time_interval_threshold(10.0) + event = _make_event_with_text("old") + event.timestamp = time.time() - 20.0 + session = _make_session(events=[event]) + assert checker(session) is True + + def test_below_threshold(self): + checker = set_summarizer_time_interval_threshold(10.0) + event = _make_event_with_text("recent") + event.timestamp = time.time() - 1.0 + session = _make_session(events=[event]) + assert checker(session) is False + + def test_default_threshold(self): + checker = set_summarizer_time_interval_threshold() + event = _make_event_with_text("recent") + event.timestamp = time.time() - 1.0 + session = _make_session(events=[event]) + assert checker(session) is False + + def test_requires_events_after_leading_summary_anchor(self): + checker = set_summarizer_time_interval_threshold(10.0) + summary_event = _make_summary_event() + summary_event.timestamp = time.time() - 20.0 + session = _make_session(events=[summary_event]) + + assert checker(session) is False + + def test_counts_flagged_invisible_events_after_latest_summary(self): + checker = set_summarizer_time_interval_threshold(10.0) + summary_event = _make_summary_event() + flagged_event = _make_event_with_text("flagged") + summary_event.timestamp = time.time() - 20.0 + flagged_event.timestamp = time.time() - 20.0 + flagged_event.set_model_visible(False) + session = _make_session(events=[summary_event, flagged_event]) + + assert checker(session) is True + + +class TestImportantContentThreshold: + """Test set_summarizer_important_content_threshold.""" + + def test_has_important_content(self): + checker = set_summarizer_important_content_threshold(5) + events = [_make_event_with_text("This is important content")] + session = _make_session(events=events) + assert checker(session) is True + + def test_no_important_content(self): + checker = set_summarizer_important_content_threshold(100) + events = [_make_event_with_text("short")] + session = _make_session(events=events) + assert checker(session) is False + + def test_empty_events(self): + checker = set_summarizer_important_content_threshold(5) + session = _make_session(events=[]) + assert checker(session) is False + + def test_event_without_content(self): + checker = set_summarizer_important_content_threshold(5) + event = Event(invocation_id="inv-1", author="agent", actions=EventActions()) + session = _make_session(events=[event]) + assert checker(session) is False + + def test_event_with_empty_parts(self): + checker = set_summarizer_important_content_threshold(5) + event = Event(invocation_id="inv-1", author="agent", content=Content(parts=[]), actions=EventActions()) + session = _make_session(events=[event]) + assert checker(session) is False + + def test_event_with_whitespace_only(self): + checker = set_summarizer_important_content_threshold(5) + events = [_make_event_with_text(" ")] + session = _make_session(events=events) + assert checker(session) is False + + def test_ignores_important_content_in_leading_summary_anchor(self): + checker = set_summarizer_important_content_threshold(5) + summary_event = _make_event_with_text("This old content is important") + summary_event.set_summary_event(True) + new_short_event = _make_event_with_text("short") + summary_event.timestamp = 2.0 + new_short_event.timestamp = 3.0 + session = _make_session(events=[summary_event, new_short_event]) + + assert checker(session) is False + + def test_counts_flagged_invisible_important_content_after_latest_summary(self): + checker = set_summarizer_important_content_threshold(5) + summary_event = _make_summary_event() + flagged_important_event = _make_event_with_text("This flagged content is important") + summary_event.timestamp = 1.0 + flagged_important_event.timestamp = 2.0 + flagged_important_event.set_model_visible(False) + session = _make_session(events=[summary_event, flagged_important_event]) + + assert checker(session) is True + + +class TestConversationThreshold: + """Test set_summarizer_conversation_threshold.""" + + def test_above_threshold(self): + checker = set_summarizer_conversation_threshold(10) + session = _make_session(conversation_count=15) + assert checker(session) is True + assert session.conversation_count == 0 + + def test_below_threshold(self): + checker = set_summarizer_conversation_threshold(10) + session = _make_session(conversation_count=5) + assert checker(session) is False + + def test_exact_threshold(self): + checker = set_summarizer_conversation_threshold(10) + session = _make_session(conversation_count=10) + assert checker(session) is False + + def test_default_threshold(self): + checker = set_summarizer_conversation_threshold() + session = _make_session(conversation_count=101) + assert checker(session) is True + + def test_resets_count_on_true(self): + checker = set_summarizer_conversation_threshold(5) + session = _make_session(conversation_count=10) + result = checker(session) + assert result is True + assert session.conversation_count == 0 + + +class TestCheckFunctionsByAnd: + """Test set_summarizer_check_functions_by_and.""" + + def test_all_true(self): + f1 = lambda s: True + f2 = lambda s: True + checker = set_summarizer_check_functions_by_and([f1, f2]) + session = _make_session() + assert checker(session) is True + + def test_one_false(self): + f1 = lambda s: True + f2 = lambda s: False + checker = set_summarizer_check_functions_by_and([f1, f2]) + session = _make_session() + assert checker(session) is False + + def test_all_false(self): + f1 = lambda s: False + f2 = lambda s: False + checker = set_summarizer_check_functions_by_and([f1, f2]) + session = _make_session() + assert checker(session) is False + + def test_empty_list(self): + checker = set_summarizer_check_functions_by_and([]) + session = _make_session() + assert checker(session) is True + + +class TestCheckFunctionsByOr: + """Test set_summarizer_check_functions_by_or.""" + + def test_all_true(self): + f1 = lambda s: True + f2 = lambda s: True + checker = set_summarizer_check_functions_by_or([f1, f2]) + session = _make_session() + assert checker(session) is True + + def test_one_true(self): + f1 = lambda s: False + f2 = lambda s: True + checker = set_summarizer_check_functions_by_or([f1, f2]) + session = _make_session() + assert checker(session) is True + + def test_all_false(self): + f1 = lambda s: False + f2 = lambda s: False + checker = set_summarizer_check_functions_by_or([f1, f2]) + session = _make_session() + assert checker(session) is False + + def test_empty_list(self): + checker = set_summarizer_check_functions_by_or([]) + session = _make_session() + assert checker(session) is False diff --git a/tests/sessions/test_summarizer_manager.py b/tests/sessions/test_summarizer_manager.py new file mode 100644 index 000000000..fa3292983 --- /dev/null +++ b/tests/sessions/test_summarizer_manager.py @@ -0,0 +1,298 @@ +# 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 trpc_agent_sdk.sessions._summarizer_manager. + +Covers: +- SummarizerSessionManager: init, set_session_service, set_summarizer, + create_session_summary, get_session_summary, should_summarize_session, + get_summarizer_metadata +""" + +from __future__ import annotations + +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._session import Session +from trpc_agent_sdk.sessions._session_summarizer import SessionSummarizer, SessionSummary +from trpc_agent_sdk.sessions._summarizer_manager import SummarizerSessionManager +from trpc_agent_sdk.types import Content, Part + + +def _make_session(events=None, conversation_count=0) -> Session: + s = Session(id="s1", app_name="app", user_id="user", save_key="app/user") + s.events = events or [] + s.conversation_count = conversation_count + return s + + +def _make_event(text="hello") -> Event: + return Event( + invocation_id="inv-1", + author="agent", + content=Content(parts=[Part.from_text(text=text)]), + ) + + +def _make_model(): + model = MagicMock() + model.name = "test-model" + return model + + +# --------------------------------------------------------------------------- +# Init +# --------------------------------------------------------------------------- + +class TestSummarizerSessionManagerInit: + def test_init_with_model(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + assert manager._summarizer is not None + assert manager._base_service is None + assert manager._auto_summarize is True + + def test_init_with_custom_summarizer(self): + model = _make_model() + summarizer = SessionSummarizer(model=model) + manager = SummarizerSessionManager(model=model, summarizer=summarizer) + assert manager._summarizer is summarizer + + def test_init_auto_summarize_false(self): + model = _make_model() + manager = SummarizerSessionManager(model=model, auto_summarize=False) + assert manager._auto_summarize is False + + +# --------------------------------------------------------------------------- +# set_session_service / set_summarizer +# --------------------------------------------------------------------------- + +class TestSetSessionService: + def test_set_service(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + mock_service = MagicMock() + manager.set_session_service(mock_service) + assert manager._base_service is mock_service + + def test_set_service_no_overwrite(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + svc1 = MagicMock() + svc2 = MagicMock() + manager.set_session_service(svc1) + manager.set_session_service(svc2) + assert manager._base_service is svc1 + + def test_set_service_force(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + svc1 = MagicMock() + svc2 = MagicMock() + manager.set_session_service(svc1) + manager.set_session_service(svc2, force=True) + assert manager._base_service is svc2 + + +class TestSetSummarizer: + def test_set_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + new_summarizer = SessionSummarizer(model=model) + original = manager._summarizer + manager.set_summarizer(new_summarizer) + assert manager._summarizer is original # won't overwrite + + def test_set_summarizer_force(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + new_summarizer = SessionSummarizer(model=model) + manager.set_summarizer(new_summarizer, force=True) + assert manager._summarizer is new_summarizer + + def test_set_summarizer_when_none(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer = None + new_summarizer = SessionSummarizer(model=model) + manager.set_summarizer(new_summarizer) + assert manager._summarizer is new_summarizer + + +# --------------------------------------------------------------------------- +# create_session_summary +# --------------------------------------------------------------------------- + +class TestCreateSessionSummary: + async def test_summary_when_should_summarize(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=True) + manager._summarizer.create_session_summary = AsyncMock(return_value="summary text") + mock_service = AsyncMock() + manager.set_session_service(mock_service) + + session = _make_session(events=[_make_event()]) + await manager.create_session_summary(session) + + manager._summarizer.create_session_summary.assert_called_once() + mock_service.update_session.assert_called_once() + + async def test_no_summary_when_should_not_summarize(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=False) + manager._summarizer.create_session_summary = AsyncMock() + + session = _make_session(events=[_make_event()]) + await manager.create_session_summary(session) + + manager._summarizer.create_session_summary.assert_not_called() + + async def test_force_summary(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=False) + manager._summarizer.create_session_summary = AsyncMock(return_value="forced summary") + mock_service = AsyncMock() + manager.set_session_service(mock_service) + + session = _make_session(events=[_make_event()]) + await manager.create_session_summary(session, force=True) + + manager._summarizer.create_session_summary.assert_called_once() + + async def test_no_base_service(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=True) + manager._summarizer.create_session_summary = AsyncMock(return_value="text") + + session = _make_session(events=[_make_event()]) + await manager.create_session_summary(session) + + async def test_summary_text_none_still_updates_session(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=True) + manager._summarizer.create_session_summary = AsyncMock(return_value=None) + mock_service = AsyncMock() + manager.set_session_service(mock_service) + + session = _make_session(events=[_make_event()]) + await manager.create_session_summary(session) + + mock_service.update_session.assert_called_once() + assert "s1" not in manager._summarizer_cache.get("app", {}).get("user", {}) + + +# --------------------------------------------------------------------------- +# get_session_summary +# --------------------------------------------------------------------------- + +class TestGetSessionSummary: + async def test_get_cached_summary(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + summary = SessionSummary( + session_id="s1", + summary_text="cached summary", + original_event_count=10, + compressed_event_count=3, + summary_timestamp=time.time(), + ) + manager._summarizer_cache = {"app": {"user": {"s1": summary}}} + session = _make_session() + result = await manager.get_session_summary(session) + assert result is summary + + async def test_get_no_cache(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + session = _make_session() + result = await manager.get_session_summary(session) + assert result is None + + async def test_get_no_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer = None + session = _make_session() + result = await manager.get_session_summary(session) + assert result is None + + async def test_get_missing_app(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer_cache = {"other_app": {}} + session = _make_session() + result = await manager.get_session_summary(session) + assert result is None + + async def test_get_missing_user(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer_cache = {"app": {"other_user": {}}} + session = _make_session() + result = await manager.get_session_summary(session) + assert result is None + + async def test_get_missing_session(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer_cache = {"app": {"user": {"other_session": MagicMock()}}} + session = _make_session() + result = await manager.get_session_summary(session) + assert result is None + + +# --------------------------------------------------------------------------- +# should_summarize_session +# --------------------------------------------------------------------------- + +class TestShouldSummarizeSession: + async def test_auto_summarize_disabled(self): + model = _make_model() + manager = SummarizerSessionManager(model=model, auto_summarize=False) + session = _make_session(events=[_make_event()]) + assert await manager.should_summarize_session(session) is False + + async def test_no_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer = None + session = _make_session(events=[_make_event()]) + assert await manager.should_summarize_session(session) is False + + async def test_delegates_to_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer.should_summarize = AsyncMock(return_value=True) + session = _make_session(events=[_make_event()]) + assert await manager.should_summarize_session(session) is True + + +# --------------------------------------------------------------------------- +# get_summarizer_metadata +# --------------------------------------------------------------------------- + +class TestGetSummarizerMetadata: + def test_with_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + metadata = manager.get_summarizer_metadata() + assert "model_name" in metadata + + def test_without_summarizer(self): + model = _make_model() + manager = SummarizerSessionManager(model=model) + manager._summarizer = None + metadata = manager.get_summarizer_metadata() + assert metadata == {} diff --git a/tests/sessions/test_types.py b/tests/sessions/test_types.py new file mode 100644 index 000000000..28b4159da --- /dev/null +++ b/tests/sessions/test_types.py @@ -0,0 +1,121 @@ +# 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 trpc_agent_sdk.sessions._types. + +Covers: +- SessionServiceConfig: creation, TTL config, expiration checks +""" + +from __future__ import annotations + +import time + +import pytest + +from trpc_agent_sdk.sessions._types import SessionServiceConfig +from trpc_agent_sdk.types import Ttl + + +class TestSessionServiceConfigDefaults: + """Test default values of SessionServiceConfig.""" + + def test_default_max_events(self): + config = SessionServiceConfig() + assert config.max_events == 0 + + def test_default_event_ttl_seconds(self): + config = SessionServiceConfig() + assert config.event_ttl_seconds == 0.0 + + def test_default_num_recent_events(self): + config = SessionServiceConfig() + assert config.num_recent_events == 0 + + def test_default_ttl_is_created(self): + config = SessionServiceConfig() + assert config.ttl is not None + assert isinstance(config.ttl, Ttl) + + def test_custom_values(self): + config = SessionServiceConfig(max_events=100, event_ttl_seconds=60.0, num_recent_events=20) + assert config.max_events == 100 + assert config.event_ttl_seconds == 60.0 + assert config.num_recent_events == 20 + + +class TestSessionServiceConfigCreateTtlConfig: + """Test create_ttl_config static method.""" + + def test_create_ttl_config_defaults(self): + ttl = SessionServiceConfig.create_ttl_config() + assert ttl.enable is True + assert ttl.ttl_seconds > 0 + assert ttl.cleanup_interval_seconds > 0 + assert ttl.update_time > 0 + + def test_create_ttl_config_custom(self): + ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=3600, cleanup_interval_seconds=120.0) + assert ttl.enable is True + assert ttl.ttl_seconds == 3600 + assert ttl.cleanup_interval_seconds == 120.0 + + def test_create_ttl_config_disabled(self): + ttl = SessionServiceConfig.create_ttl_config(enable=False, ttl_seconds=0, cleanup_interval_seconds=0.0) + assert ttl.enable is False + assert ttl.ttl_seconds == 0 + + +class TestSessionServiceConfigCleanTtl: + """Test clean_ttl_config method.""" + + def test_clean_ttl_config(self): + config = SessionServiceConfig() + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=3600, cleanup_interval_seconds=60.0) + config.clean_ttl_config() + assert config.ttl.enable is False + assert config.ttl.ttl_seconds == 0 + assert config.ttl.cleanup_interval_seconds == 0.0 + assert config.ttl.update_time == 0.0 + + +class TestSessionServiceConfigNeedTtlExpire: + """Test need_ttl_expire method.""" + + def test_need_ttl_expire_enabled(self): + config = SessionServiceConfig() + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=3600, cleanup_interval_seconds=60.0) + assert config.need_ttl_expire() is True + + def test_need_ttl_expire_disabled(self): + config = SessionServiceConfig() + config.clean_ttl_config() + assert config.need_ttl_expire() is False + + def test_need_ttl_expire_zero_ttl(self): + config = SessionServiceConfig() + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=0, cleanup_interval_seconds=60.0) + assert config.need_ttl_expire() is False + + +class TestSessionServiceConfigIsExpiredByTimestamp: + """Test is_expired_by_timestamp method.""" + + def test_not_expired(self): + config = SessionServiceConfig() + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=3600, cleanup_interval_seconds=60.0) + assert config.is_expired_by_timestamp(time.time()) is False + + def test_expired(self): + config = SessionServiceConfig() + config.ttl = SessionServiceConfig.create_ttl_config(enable=True, ttl_seconds=10, cleanup_interval_seconds=60.0) + old_timestamp = time.time() - 100 + assert config.is_expired_by_timestamp(old_timestamp) is True + + def test_not_expired_when_disabled(self): + config = SessionServiceConfig() + config.clean_ttl_config() + old_timestamp = time.time() - 100000 + assert config.is_expired_by_timestamp(old_timestamp) is False diff --git a/tests/sessions/test_utils.py b/tests/sessions/test_utils.py new file mode 100644 index 000000000..f8a74b9ba --- /dev/null +++ b/tests/sessions/test_utils.py @@ -0,0 +1,295 @@ +# 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 trpc_agent_sdk.sessions._utils. + +Covers: +- StateStorageEntry dataclass +- extract_state_delta: app/user/session/temp prefix handling +- merge_state: merge with/without copy +- session_key, app_state_key, user_state_key formatting +""" + +from __future__ import annotations + +import copy + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions._utils import ( + StateStorageEntry, + app_state_key, + extract_state_delta, + find_events_for_summary, + merge_state, + session_key, + user_state_key, +) +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import State + + +def _make_event(author: str = "agent", text: str = "hello") -> Event: + return Event(author=author, content=Content(parts=[Part.from_text(text=text)])) + + +class TestStateStorageEntry: + """Test StateStorageEntry dataclass.""" + + def test_defaults(self): + entry = StateStorageEntry() + assert entry.app_state_delta == {} + assert entry.user_state_delta == {} + assert entry.session_state == {} + + def test_custom_values(self): + entry = StateStorageEntry( + app_state_delta={"a": 1}, + user_state_delta={"b": 2}, + session_state={"c": 3}, + ) + assert entry.app_state_delta == {"a": 1} + assert entry.user_state_delta == {"b": 2} + assert entry.session_state == {"c": 3} + + +class TestExtractStateDelta: + """Test extract_state_delta function.""" + + def test_none_input(self): + result = extract_state_delta(None) + assert result.app_state_delta == {} + assert result.user_state_delta == {} + assert result.session_state == {} + + def test_empty_dict(self): + result = extract_state_delta({}) + assert result.app_state_delta == {} + assert result.user_state_delta == {} + assert result.session_state == {} + + def test_app_prefix(self): + result = extract_state_delta({f"{State.APP_PREFIX}key1": "value1"}) + assert result.app_state_delta == {"key1": "value1"} + assert result.user_state_delta == {} + assert result.session_state == {} + + def test_user_prefix(self): + result = extract_state_delta({f"{State.USER_PREFIX}key1": "value1"}) + assert result.app_state_delta == {} + assert result.user_state_delta == {"key1": "value1"} + assert result.session_state == {} + + def test_session_state_no_prefix(self): + result = extract_state_delta({"key1": "value1"}) + assert result.app_state_delta == {} + assert result.user_state_delta == {} + assert result.session_state == {"key1": "value1"} + + def test_temp_prefix_ignored(self): + result = extract_state_delta({f"{State.TEMP_PREFIX}key1": "value1"}) + assert result.app_state_delta == {} + assert result.user_state_delta == {} + assert result.session_state == {} + + def test_temp_prefix_not_ignored(self): + result = extract_state_delta({f"{State.TEMP_PREFIX}key1": "value1"}, ignore_temp=False) + assert result.session_state == {f"{State.TEMP_PREFIX}key1": "value1"} + + def test_mixed_prefixes(self): + state_delta = { + f"{State.APP_PREFIX}app_key": "app_value", + f"{State.USER_PREFIX}user_key": "user_value", + f"{State.TEMP_PREFIX}temp_key": "temp_value", + "session_key": "session_value", + } + result = extract_state_delta(state_delta) + assert result.app_state_delta == {"app_key": "app_value"} + assert result.user_state_delta == {"user_key": "user_value"} + assert result.session_state == {"session_key": "session_value"} + + def test_multiple_app_keys(self): + state_delta = { + f"{State.APP_PREFIX}k1": "v1", + f"{State.APP_PREFIX}k2": "v2", + } + result = extract_state_delta(state_delta) + assert result.app_state_delta == {"k1": "v1", "k2": "v2"} + + +class TestMergeState: + """Test merge_state function.""" + + def test_empty_entry(self): + entry = StateStorageEntry() + result = merge_state(entry) + assert result == {} + + def test_session_state_only(self): + entry = StateStorageEntry(session_state={"key": "value"}) + result = merge_state(entry) + assert result == {"key": "value"} + + def test_app_state_merged_with_prefix(self): + entry = StateStorageEntry(app_state_delta={"app_key": "app_value"}) + result = merge_state(entry) + assert result == {f"{State.APP_PREFIX}app_key": "app_value"} + + def test_user_state_merged_with_prefix(self): + entry = StateStorageEntry(user_state_delta={"user_key": "user_value"}) + result = merge_state(entry) + assert result == {f"{State.USER_PREFIX}user_key": "user_value"} + + def test_all_states_merged(self): + entry = StateStorageEntry( + app_state_delta={"a": 1}, + user_state_delta={"b": 2}, + session_state={"c": 3}, + ) + result = merge_state(entry) + assert result == {f"{State.APP_PREFIX}a": 1, f"{State.USER_PREFIX}b": 2, "c": 3} + + def test_need_copy_true(self): + original_session_state = {"key": "value"} + entry = StateStorageEntry(session_state=original_session_state) + result = merge_state(entry, need_copy=True) + result["new_key"] = "new_value" + assert "new_key" not in original_session_state + + def test_need_copy_false(self): + original_session_state = {"key": "value"} + entry = StateStorageEntry(session_state=original_session_state) + result = merge_state(entry, need_copy=False) + result["new_key"] = "new_value" + assert "new_key" in original_session_state + + +class TestFindEventsForSummary: + """Test event selection for summary generation.""" + + def test_defaults_start_from_user_and_keep_recent(self): + events = [ + _make_event(author="system", text="system preamble"), + _make_event(author="user", text="question"), + _make_event(author="agent", text="answer"), + _make_event(author="user", text="recent question"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=1) + + assert selected_events == events[1:3] + assert insert_index == 3 + + def test_first_visible_summary_event_starts_summary_window(self): + summary_event = _make_event(author="system", text="previous summary") + summary_event.set_summary_event(True) + events = [ + summary_event, + _make_event(author="agent", text="answer"), + _make_event(author="user", text="recent question"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=1) + + assert selected_events == events[:2] + assert insert_index == 2 + + def test_fallback_to_first_visible_event(self): + events = [ + _make_event(author="agent", text="answer 1"), + _make_event(author="agent", text="answer 2"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=1) + + assert selected_events == events[:1] + assert insert_index == 1 + + def test_starts_from_first_user_in_active_events(self): + old_user = _make_event(author="user", text="old question") + events = [ + _make_event(author="system", text="system preamble"), + old_user, + _make_event(author="agent", text="answer 1"), + _make_event(author="agent", text="answer 2"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=10) + + assert selected_events == events[1:] + assert insert_index == len(events) + + def test_aligns_recent_window_to_next_user_turn(self): + events = [_make_event(author="user" if idx in (8, 80, 92) else "agent", text=f"msg {idx}") for idx in range(100)] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=10) + + assert selected_events == events[8:92] + assert insert_index == 92 + + def test_keep_recent_count_uses_active_events(self): + events = [_make_event(author="agent", text=f"msg {idx}") for idx in range(20)] + + selected_events, insert_index = find_events_for_summary( + events, keep_recent_count=3, start_by_user_turn=False) + + assert selected_events == events[:17] + assert insert_index == 17 + + def test_ignores_keep_recent_when_it_would_empty_selection(self): + events = [ + _make_event(author="user", text="question"), + _make_event(author="agent", text="answer"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=10) + + assert selected_events == events + assert insert_index == len(events) + + def test_zero_keep_recent_selects_all_matching_events(self): + events = [ + _make_event(author="system", text="system preamble"), + _make_event(author="user", text="question"), + _make_event(author="agent", text="answer"), + ] + + selected_events, insert_index = find_events_for_summary(events, keep_recent_count=0) + + assert selected_events == events[1:] + assert insert_index == len(events) + + def test_no_events(self): + selected_events, insert_index = find_events_for_summary([]) + + assert selected_events == [] + assert insert_index == -1 + + +class TestKeyFunctions: + """Test key generation functions.""" + + def test_session_key(self): + assert session_key("app", "user", "sess") == "session:app:user:sess" + + def test_session_key_special_chars(self): + assert session_key("my-app", "user@domain", "s-1") == "session:my-app:user@domain:s-1" + + def test_app_state_key(self): + assert app_state_key("my_app") == "app_state:my_app" + + def test_user_state_key(self): + assert user_state_key("my_app", "user_1") == "user_state:my_app:user_1" + + def test_session_key_empty_strings(self): + assert session_key("", "", "") == "session:::" + + def test_app_state_key_empty(self): + assert app_state_key("") == "app_state:" + + def test_user_state_key_empty(self): + assert user_state_key("", "") == "user_state::" diff --git a/tests/skills/__init__.py b/tests/skills/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/skills/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/skills/hub/__init__.py b/tests/skills/hub/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/skills/hub/test_claude_marketplace.py b/tests/skills/hub/test_claude_marketplace.py new file mode 100644 index 000000000..7b1557869 --- /dev/null +++ b/tests/skills/hub/test_claude_marketplace.py @@ -0,0 +1,141 @@ +# 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 trpc_agent_sdk.skills.hub._claude_marketplace. + +Covers: +- source_id / default marketplaces / custom marketplaces +- search: identifier resolution for "./relative", "owner/repo", and bare source paths +- fetch / inspect: delegate to GitHubSource and relabel the source field +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import ClaudeMarketplaceSource +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub._claude_marketplace import DEFAULT_KNOWN_MARKETPLACES + + +class TestConstruction: + + def test_default_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + assert source._marketplaces == list(DEFAULT_KNOWN_MARKETPLACES) + + def test_custom_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["custom/repo"]) + assert source._marketplaces == ["custom/repo"] + + def test_source_id(self): + assert ClaudeMarketplaceSource(GitHubAuth()).source_id() == "claude-marketplace" + + +class TestSearch: + + def test_resolves_relative_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "./plugins/docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert len(results) == 1 + assert results[0].identifier == "anthropics/skills/plugins/docx" + assert results[0].repo == "anthropics/skills" + + def test_resolves_absolute_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "other/repo/docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert results[0].identifier == "other/repo/docx" + + def test_resolves_bare_source_path(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx") + assert results[0].identifier == "anthropics/skills/docx" + + def test_filters_non_matching_plugins(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["anthropics/skills"]) + plugins = [{"name": "docx", "description": "Word docs", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + assert source.search("notfound") == [] + + def test_respects_limit_across_marketplaces(self): + source = ClaudeMarketplaceSource(GitHubAuth(), marketplaces=["repo1", "repo2"]) + plugins = [{"name": "docx", "description": "", "source": "docx"}] + with patch.object(source, "_fetch_marketplace_index", return_value=plugins): + results = source.search("docx", limit=1) + assert len(results) == 1 + + +class TestFetch: + + def test_delegates_to_github_and_relabels_source(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + bundle = SkillBundle(name="docx", files={"SKILL.md": "body"}, source="github", identifier="anthropics/skills/docx") + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.fetch.return_value = bundle + result = source.fetch("anthropics/skills/docx") + assert result is bundle + assert result.source == "claude-marketplace" + + def test_returns_none_when_github_fetch_fails(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.fetch.return_value = None + assert source.fetch("anthropics/skills/docx") is None + + +class TestInspect: + + def test_delegates_to_github_and_relabels_source(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + meta = SkillMeta(name="docx", description="", source="github", identifier="anthropics/skills/docx") + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.inspect.return_value = meta + result = source.inspect("anthropics/skills/docx") + assert result is meta + assert result.source == "claude-marketplace" + + def test_returns_none_when_github_inspect_fails(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.GitHubSource") as mock_gh_cls: + mock_gh_cls.return_value.inspect.return_value = None + assert source.inspect("anthropics/skills/docx") is None + + +class TestFetchMarketplaceIndex: + + def test_parses_plugins_from_marketplace_json(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 200 + resp.text = '{"plugins": [{"name": "docx"}]}' + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + plugins = source._fetch_marketplace_index("anthropics/skills") + assert plugins == [{"name": "docx"}] + + def test_returns_empty_on_non_200(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 404 + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + assert source._fetch_marketplace_index("anthropics/skills") == [] + + def test_returns_empty_on_invalid_json(self): + source = ClaudeMarketplaceSource(GitHubAuth()) + resp = MagicMock() + resp.status_code = 200 + resp.text = "not json" + with patch("trpc_agent_sdk.skills.hub._claude_marketplace.httpx.get", return_value=resp): + assert source._fetch_marketplace_index("anthropics/skills") == [] diff --git a/tests/skills/hub/test_clawhub.py b/tests/skills/hub/test_clawhub.py new file mode 100644 index 000000000..acadbcd8e --- /dev/null +++ b/tests/skills/hub/test_clawhub.py @@ -0,0 +1,223 @@ +# 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 trpc_agent_sdk.skills.hub._clawhub. + +Covers: +- source_id +- _normalize_tags: list vs dict-of-version-tags vs other +- _coerce_skill_payload: nested "skill" payload merging +- _search_score: exact / prefix / substring / term-overlap scoring +- _dedupe_results: case-insensitive de-dup by identifier +- inspect: builds SkillMeta from the skill endpoint +- fetch: resolves latest version then downloads a ZIP bundle +- _extract_files: dict-of-files, list-of-file-meta with inline/raw content +""" + +from __future__ import annotations + +import io +import zipfile +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx +import pytest + +from trpc_agent_sdk.skills.hub import ClawHubSource +from trpc_agent_sdk.skills.hub import SkillMeta + + +def _resp(status_code=200, json_data=None, content=b""): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + resp.content = content + resp.headers = {} + return resp + + +class TestSourceId: + + def test_source_id(self): + assert ClawHubSource().source_id() == "clawhub" + + +class TestNormalizeTags: + + def test_list_tags(self): + assert ClawHubSource._normalize_tags(["a", "b"]) == ["a", "b"] + + def test_dict_tags_excludes_latest(self): + assert set(ClawHubSource._normalize_tags({"latest": "1.0", "1.0": "1.0"})) == {"1.0"} + + def test_other_type_returns_empty(self): + assert ClawHubSource._normalize_tags(None) == [] + + +class TestCoerceSkillPayload: + + def test_merges_nested_skill_payload(self): + data = {"skill": {"name": "notion"}, "latestVersion": "1.2.0"} + result = ClawHubSource._coerce_skill_payload(data) + assert result == {"name": "notion", "latestVersion": "1.2.0"} + + def test_returns_dict_as_is_when_no_nested_skill(self): + data = {"name": "notion"} + assert ClawHubSource._coerce_skill_payload(data) == data + + def test_non_dict_returns_none(self): + assert ClawHubSource._coerce_skill_payload(["not", "a", "dict"]) is None + + +class TestSearchScore: + + def test_exact_identifier_match_scores_highest(self): + exact = SkillMeta(name="notion", description="", source="clawhub", identifier="notion") + prefix = SkillMeta(name="notion-helper", description="", source="clawhub", identifier="notion-helper") + exact_score = ClawHubSource._search_score("notion", exact) + prefix_score = ClawHubSource._search_score("notion", prefix) + assert exact_score > prefix_score > 0 + + def test_unrelated_substring_scores_lower_than_prefix_match(self): + prefix = SkillMeta(name="notion-helper", description="", source="clawhub", identifier="notion-helper") + substring = SkillMeta(name="pro-notion-x", description="", source="clawhub", identifier="pro-notion-x") + prefix_score = ClawHubSource._search_score("notion", prefix) + substring_score = ClawHubSource._search_score("notion", substring) + assert prefix_score > substring_score > 0 + + def test_no_match_scores_zero(self): + meta = SkillMeta(name="docx", description="word docs", source="clawhub", identifier="docx") + assert ClawHubSource._search_score("notion", meta) == 0 + + def test_empty_query_scores_one(self): + meta = SkillMeta(name="docx", description="", source="clawhub", identifier="docx") + assert ClawHubSource._search_score("", meta) == 1 + + +class TestDedupeResults: + + def test_dedupes_case_insensitively_by_identifier(self): + a = SkillMeta(name="Notion", description="", source="clawhub", identifier="notion") + b = SkillMeta(name="notion again", description="", source="clawhub", identifier="Notion") + assert len(ClawHubSource._dedupe_results([a, b])) == 1 + + +class TestInspect: + + def test_builds_meta(self): + source = ClawHubSource() + data = {"displayName": "Notion", "summary": "Notion integration", "slug": "notion", "tags": ["productivity"]} + with patch.object(source, "_get_json", return_value=data): + meta = source.inspect("notion") + assert meta.name == "Notion" + assert meta.description == "Notion integration" + assert meta.source == "clawhub" + assert meta.identifier == "notion" + assert meta.tags == ["productivity"] + + def test_returns_none_when_no_data(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value=None): + assert source.inspect("notion") is None + + +class TestFetch: + + def test_returns_none_when_skill_data_missing(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value=None): + assert source.fetch("notion") is None + + def test_returns_none_when_version_unresolvable(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value={"slug": "notion"}), \ + patch.object(source, "_resolve_latest_version", return_value=None): + assert source.fetch("notion") is None + + def test_downloads_zip_bundle(self): + source = ClawHubSource() + with patch.object(source, "_get_json", return_value={"slug": "notion"}), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={"SKILL.md": "body"}): + bundle = source.fetch("notion") + assert bundle is not None + assert bundle.name == "notion" + assert bundle.files == {"SKILL.md": "body"} + assert bundle.source == "clawhub" + + def test_falls_back_to_version_metadata_when_zip_incomplete(self): + source = ClawHubSource() + version_data = {"files": {"SKILL.md": "from version metadata"}} + with patch.object(source, "_get_json", side_effect=[{"slug": "notion"}, version_data]), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={}): + bundle = source.fetch("notion") + assert bundle is not None + assert bundle.files == {"SKILL.md": "from version metadata"} + + def test_returns_none_when_no_skill_md_anywhere(self): + source = ClawHubSource() + with patch.object(source, "_get_json", side_effect=[{"slug": "notion"}, {"files": {}}]), \ + patch.object(source, "_resolve_latest_version", return_value="1.0.0"), \ + patch.object(source, "_download_zip", return_value={}): + assert source.fetch("notion") is None + + +class TestExtractFiles: + + def test_dict_file_list(self): + source = ClawHubSource() + result = source._extract_files({"files": {"SKILL.md": "body", "bad": 1}}) + assert result == {"SKILL.md": "body"} + + def test_list_file_meta_with_inline_content(self): + source = ClawHubSource() + result = source._extract_files({"files": [{"path": "SKILL.md", "content": "body"}]}) + assert result == {"SKILL.md": "body"} + + def test_list_file_meta_with_raw_url(self): + source = ClawHubSource() + with patch.object(source, "_fetch_text", return_value="fetched body"): + result = source._extract_files({"files": [{"name": "SKILL.md", "rawUrl": "https://example.com/SKILL.md"}]}) + assert result == {"SKILL.md": "fetched body"} + + def test_no_files_key_returns_empty(self): + source = ClawHubSource() + assert source._extract_files({}) == {} + + +class TestDownloadZip: + + def _make_zip_bytes(self, files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return buf.getvalue() + + def test_extracts_text_files_from_zip(self): + source = ClawHubSource() + zip_bytes = self._make_zip_bytes({"SKILL.md": b"body", "scripts/run.sh": b"echo hi"}) + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=zip_bytes)): + files = source._download_zip("notion", "1.0.0") + assert files == {"SKILL.md": "body", "scripts/run.sh": "echo hi"} + + def test_rejects_unsafe_zip_member_paths(self): + source = ClawHubSource() + zip_bytes = self._make_zip_bytes({"SKILL.md": b"body", "../../evil.sh": b"rm -rf /"}) + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=zip_bytes)): + files = source._download_zip("notion", "1.0.0") + assert files == {"SKILL.md": "body"} + + def test_returns_empty_on_non_200(self): + source = ClawHubSource() + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(status_code=404)): + assert source._download_zip("notion", "1.0.0") == {} + + def test_returns_empty_on_bad_zip(self): + source = ClawHubSource() + with patch("trpc_agent_sdk.skills.hub._clawhub.httpx.get", return_value=_resp(content=b"not a zip")): + assert source._download_zip("notion", "1.0.0") == {} diff --git a/tests/skills/hub/test_github.py b/tests/skills/hub/test_github.py new file mode 100644 index 000000000..9de866434 --- /dev/null +++ b/tests/skills/hub/test_github.py @@ -0,0 +1,340 @@ +# 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 trpc_agent_sdk.skills.hub._github. + +Covers: +- GitHubAuth: headers with/without a token +- GitHubSource.source_id / is_rate_limited +- _parse_frontmatter_quick: valid, missing, and malformed YAML frontmatter +- inspect: builds SkillMeta from SKILL.md frontmatter (incl. hermes tags override) +- fetch: builds SkillBundle from a directory tree, rejects dirs without SKILL.md +- search: filters taps by query, dedupes by name, respects limit +- rate limit detection on a 403 response with exhausted quota +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import GitHubSource + + +def _resp(status_code=200, json_data=None, text="", headers=None): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + resp.text = text + resp.headers = headers or {} + return resp + + +class TestGitHubAuth: + + def test_no_token_unauthenticated(self): + auth = GitHubAuth() + assert auth.is_authenticated() is False + headers = auth.get_headers() + assert headers["Accept"] == "application/vnd.github.v3+json" + assert "Authorization" not in headers + + def test_with_token_authenticated(self): + auth = GitHubAuth("secret-pat") + assert auth.is_authenticated() is True + headers = auth.get_headers() + assert headers["Authorization"] == "token secret-pat" + + def test_empty_token_is_unauthenticated(self): + auth = GitHubAuth("") + assert auth.is_authenticated() is False + assert "Authorization" not in auth.get_headers() + + +class TestSourceId: + + def test_source_id(self): + assert GitHubSource(GitHubAuth()).source_id() == "github" + + def test_not_rate_limited_initially(self): + assert GitHubSource(GitHubAuth()).is_rate_limited is False + + +class TestParseFrontmatterQuick: + + def test_valid_frontmatter(self): + content = "---\nname: plan\ndescription: A planning skill\n---\nBody text" + fm = GitHubSource._parse_frontmatter_quick(content) + assert fm == {"name": "plan", "description": "A planning skill"} + + def test_no_frontmatter_delimiter(self): + assert GitHubSource._parse_frontmatter_quick("just a body") == {} + + def test_unterminated_frontmatter(self): + assert GitHubSource._parse_frontmatter_quick("---\nname: plan\nno closing delimiter") == {} + + def test_malformed_yaml_returns_empty(self): + content = "---\nname: [unterminated\n---\nbody" + assert GitHubSource._parse_frontmatter_quick(content) == {} + + def test_non_dict_yaml_returns_empty(self): + content = "---\n- a\n- b\n---\nbody" + assert GitHubSource._parse_frontmatter_quick(content) == {} + + +class TestInspect: + + def test_returns_none_for_malformed_identifier(self): + source = GitHubSource(GitHubAuth()) + assert source.inspect("owner-only") is None + + def test_builds_meta_from_frontmatter(self): + source = GitHubSource(GitHubAuth()) + content = "---\nname: plan\ndescription: Plan things\ntags: [a, b]\n---\nBody" + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta is not None + assert meta.name == "plan" + assert meta.description == "Plan things" + assert meta.source == "github" + assert meta.identifier == "owner/repo/skills/plan" + assert meta.repo == "owner/repo" + assert meta.path == "skills/plan" + assert meta.tags == ["a", "b"] + + def test_hermes_metadata_tags_take_priority(self): + source = GitHubSource(GitHubAuth()) + content = ( + "---\n" + "name: plan\n" + "description: Plan things\n" + "tags: [fallback]\n" + "metadata:\n" + " hermes:\n" + " tags: [priority]\n" + "---\n" + "Body" + ) + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.tags == ["priority"] + + def test_empty_hermes_tags_list_falls_back_to_raw_tags(self): + # An empty (but present) `metadata.hermes.tags: []` must not shadow a + # populated top-level `tags:` list. + source = GitHubSource(GitHubAuth()) + content = ( + "---\n" + "name: plan\n" + "description: Plan things\n" + "tags: [a, b]\n" + "metadata:\n" + " hermes:\n" + " tags: []\n" + "---\n" + "Body" + ) + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.tags == ["a", "b"] + + def test_returns_none_when_content_missing(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_fetch_file_content", return_value=None): + assert source.inspect("owner/repo/plan") is None + + def test_falls_back_to_dir_name_when_frontmatter_lacks_name(self): + source = GitHubSource(GitHubAuth()) + content = "---\ndescription: no name field\n---\nBody" + with patch.object(source, "_fetch_file_content", return_value=content): + meta = source.inspect("owner/repo/skills/plan") + assert meta.name == "plan" + + +class TestFetch: + + def test_returns_none_for_malformed_identifier(self): + source = GitHubSource(GitHubAuth()) + assert source.fetch("owner-only") is None + + def test_returns_none_when_no_skill_md(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory", return_value={"other.txt": "x"}): + assert source.fetch("owner/repo/skills/plan") is None + + def test_returns_none_when_directory_empty(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory", return_value={}): + assert source.fetch("owner/repo/skills/plan") is None + + def test_builds_bundle_from_downloaded_files(self): + source = GitHubSource(GitHubAuth()) + files = {"SKILL.md": "---\nname: plan\n---\nbody", "scripts/run.sh": "echo hi"} + with patch.object(source, "_download_directory", return_value=files): + bundle = source.fetch("owner/repo/skills/plan") + assert bundle is not None + assert bundle.name == "plan" + assert bundle.files == files + assert bundle.source == "github" + assert bundle.identifier == "owner/repo/skills/plan" + + +class TestSearch: + + def test_no_taps_returns_empty(self): + source = GitHubSource(GitHubAuth()) + assert source.search("anything") == [] + + def test_filters_by_query_across_taps(self): + source = GitHubSource(GitHubAuth(), taps=[{"repo": "owner/repo", "path": "skills/"}]) + + def fake_list_skills_in_repo(repo, path): + from trpc_agent_sdk.skills.hub import SkillMeta + return [ + SkillMeta(name="plan", description="planning skill", source="github", identifier="owner/repo/skills/plan"), + SkillMeta(name="docx", description="word docs", source="github", identifier="owner/repo/skills/docx"), + ] + + with patch.object(source, "_list_skills_in_repo", side_effect=fake_list_skills_in_repo): + results = source.search("plan") + assert len(results) == 1 + assert results[0].name == "plan" + + def test_dedupes_by_name_and_respects_limit(self): + source = GitHubSource( + GitHubAuth(), + taps=[{"repo": "owner/repo1"}, {"repo": "owner/repo2"}], + ) + + def fake_list_skills_in_repo(repo, path): + from trpc_agent_sdk.skills.hub import SkillMeta + return [SkillMeta(name="plan", description="", source="github", identifier=f"{repo}/plan")] + + with patch.object(source, "_list_skills_in_repo", side_effect=fake_list_skills_in_repo): + results = source.search("", limit=10) + assert len(results) == 1 + + def test_tap_error_is_skipped(self): + source = GitHubSource(GitHubAuth(), taps=[{"repo": "owner/repo"}]) + with patch.object(source, "_list_skills_in_repo", side_effect=RuntimeError("boom")): + assert source.search("anything") == [] + + +class TestRateLimit: + + def test_flags_rate_limited_on_403_with_exhausted_quota(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "0"}) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is True + + def test_403_with_remaining_quota_is_not_rate_limited(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "10"}) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is False + + def test_non_403_does_not_flag_rate_limit(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=404) + source._check_rate_limit_response(resp) + assert source.is_rate_limited is False + + def test_fetch_file_content_flags_rate_limit_via_httpx_get(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=403, headers={"X-RateLimit-Remaining": "0"}) + with patch("trpc_agent_sdk.skills.hub._github.httpx.get", return_value=resp): + content = source._fetch_file_content("owner/repo", "SKILL.md") + assert content is None + assert source.is_rate_limited is True + + +class TestFetchFileContent: + + def test_returns_text_on_200(self): + source = GitHubSource(GitHubAuth()) + resp = _resp(status_code=200, text="file contents") + with patch("trpc_agent_sdk.skills.hub._github.httpx.get", return_value=resp): + assert source._fetch_file_content("owner/repo", "SKILL.md") == "file contents" + + def test_returns_none_on_http_error(self): + source = GitHubSource(GitHubAuth()) + with patch( + "trpc_agent_sdk.skills.hub._github.httpx.get", + side_effect=httpx.ConnectError("boom"), + ): + assert source._fetch_file_content("owner/repo", "SKILL.md") is None + + +class TestDownloadDirectory: + + def test_falls_back_to_contents_api_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory_via_tree", return_value=None), \ + patch.object(source, "_download_directory_recursive", return_value={"SKILL.md": "body"}) as recursive_mock: + files = source._download_directory("owner/repo", "skills/plan") + recursive_mock.assert_called_once_with("owner/repo", "skills/plan") + assert files == {"SKILL.md": "body"} + + def test_uses_tree_result_when_available(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_download_directory_via_tree", return_value={"SKILL.md": "body"}), \ + patch.object(source, "_download_directory_recursive") as recursive_mock: + files = source._download_directory("owner/repo", "skills/plan") + recursive_mock.assert_not_called() + assert files == {"SKILL.md": "body"} + + def test_download_directory_via_tree_path_not_found_returns_empty_dict(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=("main", [{"type": "blob", "path": "other/file.txt"}])): + result = source._download_directory_via_tree("owner/repo", "skills/plan") + assert result == {} + + def test_download_directory_via_tree_none_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=None): + assert source._download_directory_via_tree("owner/repo", "skills/plan") is None + + def test_download_directory_via_tree_fetches_matching_blobs(self): + source = GitHubSource(GitHubAuth()) + tree_entries = [ + {"type": "blob", "path": "skills/plan/SKILL.md"}, + {"type": "blob", "path": "skills/plan/scripts/run.sh"}, + {"type": "tree", "path": "skills/plan/scripts"}, + {"type": "blob", "path": "skills/other/SKILL.md"}, + ] + with patch.object(source, "_get_repo_tree", return_value=("main", tree_entries)), \ + patch.object(source, "_fetch_file_content", side_effect=lambda repo, path: f"content:{path}"): + files = source._download_directory_via_tree("owner/repo", "skills/plan") + assert files == { + "SKILL.md": "content:skills/plan/SKILL.md", + "scripts/run.sh": "content:skills/plan/scripts/run.sh", + } + + +class TestFindSkillInRepoTree: + + def test_finds_skill_dir_by_suffix_match(self): + source = GitHubSource(GitHubAuth()) + tree_entries = [ + {"type": "blob", "path": "components/skills/dev/plan/SKILL.md"}, + ] + with patch.object(source, "_get_repo_tree", return_value=("main", tree_entries)): + result = source._find_skill_in_repo_tree("owner/repo", "plan") + assert result == "owner/repo/components/skills/dev/plan" + + def test_returns_none_when_not_found(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=("main", [])): + assert source._find_skill_in_repo_tree("owner/repo", "plan") is None + + def test_returns_none_when_tree_unavailable(self): + source = GitHubSource(GitHubAuth()) + with patch.object(source, "_get_repo_tree", return_value=None): + assert source._find_skill_in_repo_tree("owner/repo", "plan") is None diff --git a/tests/skills/hub/test_hermes_index.py b/tests/skills/hub/test_hermes_index.py new file mode 100644 index 000000000..9520a6c9f --- /dev/null +++ b/tests/skills/hub/test_hermes_index.py @@ -0,0 +1,219 @@ +# 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 trpc_agent_sdk.skills.hub._hermes_index. + +Covers: +- source_id / is_available +- search: empty query returns featured, non-empty filters by text, respects limit +- inspect / _find_entry: exact match, prefix-normalized match, no match +- fetch: resolved_github_id delegation, repo+path fallback delegation, no-match +- index loading is cached across calls (single fetch) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub._hermes_index import HermesIndexSource + + +_INDEX = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "description": "Plan things", + "tags": ["planning"], + "source": "github", + "repo": "owner/repo", + "path": "skills/plan", + }, + { + "identifier": "skills-sh/owner2/repo2/docx", + "name": "docx", + "description": "Word docs", + "tags": [], + "source": "skills.sh", + "repo": "owner2/repo2", + "path": "docx", + }, + ] +} + + +class TestSourceId: + + def test_source_id(self): + assert HermesIndexSource(GitHubAuth()).source_id() == "hermes-index" + + +class TestIsAvailable: + + def test_unavailable_when_index_load_fails(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=None): + assert source.is_available is False + + def test_available_when_index_has_skills(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.is_available is True + + def test_index_is_loaded_only_once(self): + source = HermesIndexSource(GitHubAuth()) + with patch( + "trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX + ) as load_mock: + assert source.is_available is True + assert source.is_available is True + load_mock.assert_called_once() + + +class TestSearch: + + def test_empty_query_returns_featured_up_to_limit(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + results = source.search("", limit=1) + assert len(results) == 1 + assert results[0].name == "plan" + + def test_filters_by_text(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + results = source.search("docx") + assert len(results) == 1 + assert results[0].name == "docx" + + def test_no_skills_in_index_returns_empty(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value={"skills": []}): + assert source.search("anything") == [] + + def test_unavailable_index_returns_empty(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=None): + assert source.search("anything") == [] + + +class TestFindEntryAndInspect: + + def test_exact_identifier_match(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + meta = source.inspect("owner/repo/skills/plan") + assert meta is not None + assert meta.name == "plan" + + def test_prefix_normalized_match(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + meta = source.inspect("owner2/repo2/docx") + assert meta is not None + assert meta.name == "docx" + + def test_no_match_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.inspect("nonexistent") is None + + +class TestFetch: + + def test_uses_resolved_github_id_when_present(self): + source = HermesIndexSource(GitHubAuth()) + index = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "resolved_github_id": "owner/repo/actual/path/plan", + "source": "github", + } + ] + } + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="resolved") + fake_github = MagicMock() + fake_github.fetch.return_value = bundle + + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=index), \ + patch.object(source, "_get_github", return_value=fake_github): + result = source.fetch("owner/repo/skills/plan") + + fake_github.fetch.assert_called_once_with("owner/repo/actual/path/plan") + assert result is bundle + assert result.identifier == "owner/repo/skills/plan" + + def test_falls_back_to_repo_and_path(self): + source = HermesIndexSource(GitHubAuth()) + index = { + "skills": [ + { + "identifier": "owner/repo/skills/plan", + "name": "plan", + "repo": "owner/repo", + "path": "skills/plan", + "source": "github", + } + ] + } + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="x") + fake_github = MagicMock() + fake_github.fetch.return_value = bundle + + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=index), \ + patch.object(source, "_get_github", return_value=fake_github): + result = source.fetch("owner/repo/skills/plan") + + fake_github.fetch.assert_called_once_with("owner/repo/skills/plan") + assert result is bundle + + def test_no_entry_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX): + assert source.fetch("nonexistent") is None + + def test_github_fetch_failure_returns_none(self): + source = HermesIndexSource(GitHubAuth()) + fake_github = MagicMock() + fake_github.fetch.return_value = None + with patch("trpc_agent_sdk.skills.hub._hermes_index._load_hermes_index", return_value=_INDEX), \ + patch.object(source, "_get_github", return_value=fake_github): + assert source.fetch("owner/repo/skills/plan") is None + + +class TestLoadHermesIndex: + + def test_load_returns_none_on_non_200(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 404 + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() is None + + def test_load_returns_data_on_200(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = _INDEX + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() == _INDEX + + def test_load_returns_none_when_skills_key_missing(self): + from trpc_agent_sdk.skills.hub._hermes_index import _load_hermes_index + + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"no_skills_key": True} + with patch("trpc_agent_sdk.skills.hub._hermes_index.httpx.get", return_value=resp): + assert _load_hermes_index() is None diff --git a/tests/skills/hub/test_install.py b/tests/skills/hub/test_install.py new file mode 100644 index 000000000..6cf974b03 --- /dev/null +++ b/tests/skills/hub/test_install.py @@ -0,0 +1,294 @@ +"""Tests for Skills Hub remote-skill installation helpers.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from trpc_agent_sdk.skills import create_default_skill_repository +from trpc_agent_sdk.skills.hub import SkillSpec +from trpc_agent_sdk.skills.hub import SkillSpecsConfig +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillSource +from trpc_agent_sdk.skills.hub import sync_remote_skills +from trpc_agent_sdk.skills.hub._install import _fetch_remote_skill +from trpc_agent_sdk.skills.hub._install import _find_existing_skill_dirs +from trpc_agent_sdk.skills.hub._install import _write_bundle_files + + +class FakeSource(SkillSource): + """Minimal in-memory SkillSource for install tests.""" + + def __init__(self, bundle: SkillBundle | None = None, *, raise_on_fetch: bool = False): + self.bundle = bundle + self.raise_on_fetch = raise_on_fetch + self.fetch_calls: list[str] = [] + + def source_id(self) -> str: + return "fake" + + def search(self, query: str, limit: int = 10) -> list: + return [] + + def inspect(self, identifier: str): + return None + + def fetch(self, identifier: str) -> SkillBundle | None: + self.fetch_calls.append(identifier) + if self.raise_on_fetch: + raise RuntimeError("network boom") + return self.bundle + + +def _bundle(name: str = "plan", **metadata) -> SkillBundle: + return SkillBundle( + name=name, + files={ + "SKILL.md": f"---\nname: {name}\ndescription: test skill\n---\nbody", + "scripts/run.sh": "echo hi", + }, + source="fake", + identifier="whatever", + metadata=metadata, + ) + + +class TestRemoteSkillValidation: + + def test_valid_name_is_accepted(self): + remote_skill = SkillSpec(source=FakeSource(), identifier="id", name="plan") + assert remote_skill.name == "plan" + + def test_unsafe_name_raises(self): + with pytest.raises(ValueError): + SkillSpec(source=FakeSource(), identifier="id", name="../escape") + + def test_nested_name_raises(self): + with pytest.raises(ValueError): + SkillSpec(source=FakeSource(), identifier="id", name="a/b") + + def test_defaults(self): + remote_skill = SkillSpec(source=FakeSource(), identifier="id", name="plan") + assert remote_skill.category is None + assert remote_skill.replace_if_exists is False + assert remote_skill.on_error == "skip" + + +class TestFindExistingSkillDirs: + + def test_missing_skills_path_returns_empty(self, tmp_path: Path): + assert _find_existing_skill_dirs(tmp_path / "nonexistent", "plan") == [] + + def test_finds_across_multiple_categories(self, tmp_path: Path): + dir1 = tmp_path / "hub" / "plan" + dir2 = tmp_path / "dev" / "plan" + dir1.mkdir(parents=True) + dir2.mkdir(parents=True) + + found = _find_existing_skill_dirs(tmp_path, "plan") + + assert sorted(found) == sorted([dir1, dir2]) + + def test_ignores_hidden_category_dirs(self, tmp_path: Path): + (tmp_path / ".tmp" / "plan").mkdir(parents=True) + assert _find_existing_skill_dirs(tmp_path, "plan") == [] + + +class TestWriteBundleFiles: + + def test_writes_text_and_bytes(self, tmp_path: Path): + _write_bundle_files( + skills_path=tmp_path, + category="hub", + name="plan", + files={"SKILL.md": "body", "assets/logo.png": b"\x89PNG"}, + ) + + target = tmp_path / "hub" / "plan" + assert (target / "SKILL.md").read_text() == "body" + assert (target / "assets" / "logo.png").read_bytes() == b"\x89PNG" + + def test_overwrites_existing_target_dir(self, tmp_path: Path): + target = tmp_path / "hub" / "plan" + target.mkdir(parents=True) + (target / "stale.txt").write_text("old") + + _write_bundle_files(skills_path=tmp_path, category="hub", name="plan", files={"SKILL.md": "new"}) + + assert not (target / "stale.txt").exists() + assert (target / "SKILL.md").read_text() == "new" + + def test_rejects_unsafe_category(self, tmp_path: Path): + with pytest.raises(ValueError): + _write_bundle_files(skills_path=tmp_path, category="../escape", name="plan", files={"SKILL.md": "x"}) + + def test_rejects_unsafe_bundle_path_and_cleans_up(self, tmp_path: Path): + with pytest.raises(ValueError): + _write_bundle_files( + skills_path=tmp_path, + category="hub", + name="plan", + files={"../escape.txt": "x"}, + ) + assert not (tmp_path / "hub" / "plan").exists() + assert not (tmp_path / ".tmp").exists() or not any((tmp_path / ".tmp").iterdir()) + + +class TestFetchRemoteSkill: + + def test_skips_when_already_installed(self, tmp_path: Path): + (tmp_path / "hub" / "plan").mkdir(parents=True) + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == [] + + def test_fetches_and_writes_when_missing(self, tmp_path: Path): + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="fetch-me", name="plan", category="hub") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == ["fetch-me"] + assert (tmp_path / "hub" / "plan" / "SKILL.md").exists() + + def test_raises_when_fetch_returns_none(self, tmp_path: Path): + source = FakeSource(bundle=None) + remote_skill = SkillSpec(source=source, identifier="missing", name="plan") + + with pytest.raises(ValueError, match="could not fetch"): + _fetch_remote_skill(remote_skill, tmp_path) + + def test_replace_if_exists_refetches_and_overwrites(self, tmp_path: Path): + existing = tmp_path / "hub" / "plan" + existing.mkdir(parents=True) + (existing / "OLD.md").write_text("old") + + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan", category="hub", replace_if_exists=True) + + _fetch_remote_skill(remote_skill, tmp_path) + + assert source.fetch_calls == ["id"] + assert not (existing / "OLD.md").exists() + assert (existing / "SKILL.md").exists() + + def test_category_resolution(self, tmp_path: Path): + source = FakeSource(bundle=_bundle(category="dev")) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert (tmp_path / "dev" / "plan" / "SKILL.md").exists() + + def test_explicit_category_wins_over_bundle_metadata(self, tmp_path: Path): + source = FakeSource(bundle=_bundle(category="dev")) + remote_skill = SkillSpec(source=source, identifier="id", name="plan", category="custom") + + _fetch_remote_skill(remote_skill, tmp_path) + + assert (tmp_path / "custom" / "plan" / "SKILL.md").exists() + assert not (tmp_path / "dev").exists() + + +class TestSyncRemoteSkills: + + def test_empty_remote_skills_is_noop(self, tmp_path: Path): + target = tmp_path / "skills" + sync_remote_skills([], target) + assert not target.exists() + + def test_creates_install_root(self, tmp_path: Path): + target = tmp_path / "skills" + source = FakeSource(bundle=_bundle()) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + sync_remote_skills([remote_skill], target) + + assert target.is_dir() + assert (target / "hub" / "plan" / "SKILL.md").exists() + + def test_on_error_skip_continues_to_next_remote_skill(self, tmp_path: Path): + failing = FakeSource(bundle=None) + succeeding = FakeSource(bundle=_bundle()) + remote_skills = [ + SkillSpec(source=failing, identifier="fails", name="broken", on_error="skip"), + SkillSpec(source=succeeding, identifier="works", name="plan", on_error="skip"), + ] + + sync_remote_skills(remote_skills, tmp_path) + + assert not (tmp_path / "hub" / "broken").exists() + assert (tmp_path / "hub" / "plan" / "SKILL.md").exists() + + def test_on_error_raise_propagates_and_stops(self, tmp_path: Path): + failing = FakeSource(bundle=None) + succeeding = FakeSource(bundle=_bundle()) + remote_skills = [ + SkillSpec(source=failing, identifier="fails", name="broken", on_error="raise"), + SkillSpec(source=succeeding, identifier="works", name="plan"), + ] + + with pytest.raises(ValueError, match="could not fetch"): + sync_remote_skills(remote_skills, tmp_path) + + assert succeeding.fetch_calls == [] + + def test_fetch_exception_is_skipped_by_default(self, tmp_path: Path): + source = FakeSource(raise_on_fetch=True) + remote_skill = SkillSpec(source=source, identifier="id", name="plan") + + sync_remote_skills([remote_skill], tmp_path) + + assert not (tmp_path / "hub" / "plan").exists() + + +class TestCreateDefaultSkillRepositoryRemoteSkills: + + def test_additional_skill_specs_are_installed_and_indexed(self, tmp_path: Path): + install_root = tmp_path / "downloaded" + source = FakeSource(bundle=_bundle(name="plan")) + + repository = create_default_skill_repository( + additional_skill_specs=SkillSpecsConfig( + specs=[SkillSpec(source=source, identifier="id", name="plan")], + install_path=str(install_root), + ), + use_cached_repository=False, + ) + + assert source.fetch_calls == ["id"] + assert repository.skill_list() == ["plan"] + assert repository.path("plan") == str(install_root / "hub" / "plan") + + def test_install_path_defaults_to_system_temp_dir(self): + config = SkillSpecsConfig( + specs=[SkillSpec(source=FakeSource(bundle=_bundle()), identifier="id", name="plan")], + ) + + assert config.install_path == str(Path(tempfile.gettempdir()) / "trpc_agent_skills") + + def test_local_roots_precede_remote_install_root(self, tmp_path: Path): + local_root = tmp_path / "local" + local_skill = local_root / "local-category" / "plan" + local_skill.mkdir(parents=True) + (local_skill / "SKILL.md").write_text("---\nname: plan\ndescription: local\n---\nlocal body") + + install_root = tmp_path / "downloaded" + source = FakeSource(bundle=_bundle(name="plan")) + + repository = create_default_skill_repository( + str(local_root), + additional_skill_specs=SkillSpecsConfig( + specs=[SkillSpec(source=source, identifier="id", name="plan")], + install_path=str(install_root), + ), + use_cached_repository=False, + ) + + assert repository.path("plan") == str(local_skill) + assert (install_root / "hub" / "plan" / "SKILL.md").exists() diff --git a/tests/skills/hub/test_lobehub.py b/tests/skills/hub/test_lobehub.py new file mode 100644 index 000000000..635a4c886 --- /dev/null +++ b/tests/skills/hub/test_lobehub.py @@ -0,0 +1,134 @@ +# 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 trpc_agent_sdk.skills.hub._lobehub. + +Covers: +- source_id +- search: filters agents by query text, respects the "lobehub/" identifier prefix +- inspect: exact identifier match against the index +- fetch: converts the agent JSON into a synthetic SKILL.md bundle +- _convert_to_skill_md: frontmatter + body shape +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import LobeHubSource + + +_INDEX = { + "agents": [ + { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing"]}, + }, + { + "identifier": "coder-bot", + "meta": {"title": "Coder Bot", "description": "Helps you code.", "tags": ["coding"]}, + }, + ] +} + + +class TestSourceId: + + def test_source_id(self): + assert LobeHubSource().source_id() == "lobehub" + + +class TestSearch: + + def test_filters_by_query(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + results = source.search("write") + assert len(results) == 1 + assert results[0].identifier == "lobehub/writer-bot" + + def test_index_unavailable_returns_empty(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=None): + assert source.search("anything") == [] + + def test_respects_limit(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + results = source.search("bot", limit=1) + assert len(results) == 1 + + +class TestInspect: + + def test_finds_exact_identifier(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + meta = source.inspect("lobehub/writer-bot") + assert meta is not None + assert meta.identifier == "lobehub/writer-bot" + assert meta.description == "Helps you write." + + def test_strips_lobehub_prefix(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + meta = source.inspect("writer-bot") + assert meta is not None + + def test_returns_none_when_not_found(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=_INDEX): + assert source.inspect("nonexistent") is None + + def test_returns_none_when_index_unavailable(self): + source = LobeHubSource() + with patch.object(source, "_fetch_index", return_value=None): + assert source.inspect("writer-bot") is None + + +class TestFetch: + + def test_builds_bundle_from_agent_json(self): + source = LobeHubSource() + agent_data = { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing"]}, + "config": {"systemRole": "You are a writing assistant."}, + } + with patch.object(source, "_fetch_agent", return_value=agent_data): + bundle = source.fetch("lobehub/writer-bot") + assert bundle is not None + assert bundle.name == "writer-bot" + assert bundle.identifier == "lobehub/writer-bot" + assert "SKILL.md" in bundle.files + assert "Writer Bot" in bundle.files["SKILL.md"] + assert "You are a writing assistant." in bundle.files["SKILL.md"] + + def test_returns_none_when_agent_fetch_fails(self): + source = LobeHubSource() + with patch.object(source, "_fetch_agent", return_value=None): + assert source.fetch("lobehub/writer-bot") is None + + +class TestConvertToSkillMd: + + def test_includes_frontmatter_and_body(self): + agent_data = { + "identifier": "writer-bot", + "meta": {"title": "Writer Bot", "description": "Helps you write.", "tags": ["writing", "assistant"]}, + "config": {"systemRole": "Be helpful."}, + } + md = LobeHubSource._convert_to_skill_md(agent_data) + assert md.startswith("---\n") + assert "name: writer-bot" in md + assert "# Writer Bot" in md + assert "Be helpful." in md + + def test_missing_system_role_uses_placeholder(self): + agent_data = {"identifier": "writer-bot", "meta": {"title": "Writer Bot"}} + md = LobeHubSource._convert_to_skill_md(agent_data) + assert "(No system role defined)" in md diff --git a/tests/skills/hub/test_skills_sh.py b/tests/skills/hub/test_skills_sh.py new file mode 100644 index 000000000..f3117de66 --- /dev/null +++ b/tests/skills/hub/test_skills_sh.py @@ -0,0 +1,150 @@ +# 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 trpc_agent_sdk.skills.hub._skills_sh. + +Covers: +- source_id +- _normalize_identifier: alias-prefix stripping +- _candidate_identifiers: standard skill-path candidates, deduped +- _wrap_identifier +- _token_variants: slug/case/underscore normalization used for fuzzy matching +- fetch: resolves via first matching candidate through the underlying GitHubSource +- inspect: delegates to GitHub inspect via candidates, then discovery fallback +""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub import GitHubAuth +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub._skills_sh import SkillsShSource + + +class TestSourceId: + + def test_source_id(self): + assert SkillsShSource(GitHubAuth()).source_id() == "skills-sh" + + +class TestNormalizeIdentifier: + + @pytest.mark.parametrize( + "raw,expected", + [ + ("skills-sh/owner/repo/plan", "owner/repo/plan"), + ("skills.sh/owner/repo/plan", "owner/repo/plan"), + ("owner/repo/plan", "owner/repo/plan"), + ], + ) + def test_strips_known_prefixes(self, raw, expected): + assert SkillsShSource._normalize_identifier(raw) == expected + + +class TestCandidateIdentifiers: + + def test_generates_standard_paths(self): + candidates = SkillsShSource._candidate_identifiers("owner/repo/plan") + assert candidates == [ + "owner/repo/plan", + "owner/repo/skills/plan", + "owner/repo/.agents/skills/plan", + "owner/repo/.claude/skills/plan", + ] + + def test_short_identifier_returned_as_is(self): + assert SkillsShSource._candidate_identifiers("owner/repo") == ["owner/repo"] + + def test_dedupes_when_skill_path_already_prefixed(self): + candidates = SkillsShSource._candidate_identifiers("owner/repo/skills/plan") + assert candidates.count("owner/repo/skills/plan") == 1 + + +class TestWrapIdentifier: + + def test_wraps_with_prefix(self): + assert SkillsShSource._wrap_identifier("owner/repo/plan") == "skills-sh/owner/repo/plan" + + +class TestTokenVariants: + + def test_generates_case_and_separator_variants(self): + variants = SkillsShSource._token_variants("Skill_Creator") + assert "skill_creator" in variants + assert "skill-creator" in variants + + def test_empty_value_returns_empty_set(self): + assert SkillsShSource._token_variants(None) == set() + assert SkillsShSource._token_variants("") == set() + + def test_strips_html_tags(self): + variants = SkillsShSource._token_variants("plan") + assert "plan" in variants + + +class TestMatchesSkillTokens: + + def test_matches_on_name(self): + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/skills/plan") + assert SkillsShSource._matches_skill_tokens(meta, ["plan"]) is True + + def test_no_match(self): + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/skills/plan") + assert SkillsShSource._matches_skill_tokens(meta, ["docx"]) is False + + +class TestFetch: + + def test_returns_bundle_from_first_matching_candidate(self): + source = SkillsShSource(GitHubAuth()) + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="owner/repo/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", return_value=bundle) as fetch_mock: + result = source.fetch("owner/repo/plan") + assert result is bundle + assert result.source == "skills.sh" + assert result.identifier == "skills-sh/owner/repo/plan" + fetch_mock.assert_called_once_with("owner/repo/plan") + + def test_falls_back_to_discovery_when_no_candidate_matches(self): + source = SkillsShSource(GitHubAuth()) + bundle = SkillBundle(name="plan", files={"SKILL.md": "body"}, source="github", identifier="owner/repo/other/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", side_effect=[None, None, None, None, bundle]), \ + patch.object(source, "_discover_identifier", return_value="owner/repo/other/plan"): + result = source.fetch("owner/repo/plan") + assert result is bundle + + def test_returns_none_when_nothing_resolves(self): + source = SkillsShSource(GitHubAuth()) + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "fetch", return_value=None), \ + patch.object(source, "_discover_identifier", return_value=None): + assert source.fetch("owner/repo/plan") is None + + +class TestInspect: + + def test_returns_meta_from_candidate(self): + source = SkillsShSource(GitHubAuth()) + meta = SkillMeta(name="plan", description="", source="github", identifier="owner/repo/plan") + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "inspect", return_value=meta): + result = source.inspect("owner/repo/plan") + assert result is not None + assert result.source == "skills.sh" + assert result.identifier == "skills-sh/owner/repo/plan" + + def test_returns_none_when_unresolvable(self): + source = SkillsShSource(GitHubAuth()) + with patch.object(source, "_fetch_detail_page", return_value=None), \ + patch.object(source.github, "inspect", return_value=None), \ + patch.object(source, "_discover_identifier", return_value=None): + assert source.inspect("owner/repo/plan") is None diff --git a/tests/skills/hub/test_source.py b/tests/skills/hub/test_source.py new file mode 100644 index 000000000..96d444340 --- /dev/null +++ b/tests/skills/hub/test_source.py @@ -0,0 +1,56 @@ +# 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 trpc_agent_sdk.skills.hub._source. + +Covers: +- SkillSource cannot be instantiated directly (ABC contract) +- A subclass missing any abstract method also cannot be instantiated +- A subclass implementing all four methods can be instantiated and used +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub import SkillSource + + +class TestSkillSourceContract: + + def test_cannot_instantiate_abstract_class(self): + with pytest.raises(TypeError): + SkillSource() # type: ignore[abstract] + + def test_subclass_missing_methods_cannot_instantiate(self): + class Incomplete(SkillSource): + def source_id(self) -> str: + return "incomplete" + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + def test_complete_subclass_is_instantiable(self): + class Complete(SkillSource): + def source_id(self) -> str: + return "complete" + + def search(self, query: str, limit: int = 10) -> list[SkillMeta]: + return [] + + def inspect(self, identifier: str) -> SkillMeta | None: + return None + + def fetch(self, identifier: str) -> SkillBundle | None: + return None + + source = Complete() + assert source.source_id() == "complete" + assert source.search("q") == [] + assert source.inspect("id") is None + assert source.fetch("id") is None + assert isinstance(source, SkillSource) diff --git a/tests/skills/hub/test_types.py b/tests/skills/hub/test_types.py new file mode 100644 index 000000000..c910d6553 --- /dev/null +++ b/tests/skills/hub/test_types.py @@ -0,0 +1,155 @@ +# 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 trpc_agent_sdk.skills.hub._types. + +Covers: +- SkillMeta / SkillBundle dataclass defaults +- validate_skill_name / validate_category_name: single-segment-only paths +- validate_bundle_rel_path: nested paths allowed +- Path traversal / absolute-path / Windows-drive / non-string rejection +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.skills.hub import SkillBundle +from trpc_agent_sdk.skills.hub import SkillMeta +from trpc_agent_sdk.skills.hub import validate_bundle_rel_path +from trpc_agent_sdk.skills.hub import validate_category_name +from trpc_agent_sdk.skills.hub import validate_skill_name + + +class TestSkillMeta: + + def test_required_fields(self): + meta = SkillMeta(name="plan", description="desc", source="github", identifier="owner/repo/plan") + assert meta.name == "plan" + assert meta.description == "desc" + assert meta.source == "github" + assert meta.identifier == "owner/repo/plan" + + def test_defaults(self): + meta = SkillMeta(name="plan", description="desc", source="github", identifier="owner/repo/plan") + assert meta.repo is None + assert meta.path is None + assert meta.tags == [] + assert meta.extra == {} + + def test_default_collections_are_independent_per_instance(self): + a = SkillMeta(name="a", description="", source="s", identifier="a") + b = SkillMeta(name="b", description="", source="s", identifier="b") + a.tags.append("x") + a.extra["k"] = "v" + assert b.tags == [] + assert b.extra == {} + + +class TestSkillBundle: + + def test_required_fields(self): + bundle = SkillBundle( + name="plan", + files={"SKILL.md": "---\nname: plan\n---\nbody"}, + source="github", + identifier="owner/repo/plan", + ) + assert bundle.name == "plan" + assert bundle.files["SKILL.md"].startswith("---") + assert bundle.metadata == {} + + def test_files_can_hold_bytes(self): + bundle = SkillBundle( + name="plan", + files={"SKILL.md": "text", "logo.png": b"\x89PNG"}, + source="github", + identifier="id", + ) + assert isinstance(bundle.files["logo.png"], bytes) + + def test_default_metadata_independent_per_instance(self): + a = SkillBundle(name="a", files={}, source="s", identifier="a") + b = SkillBundle(name="b", files={}, source="s", identifier="b") + a.metadata["category"] = "dev" + assert b.metadata == {} + + +class TestValidateSkillName: + + @pytest.mark.parametrize("name", ["plan", "skill-creator", "skill_1", "a"]) + def test_valid_single_segment_names(self, name): + assert validate_skill_name(name) == name + + def test_strips_whitespace(self): + assert validate_skill_name(" plan ") == "plan" + + @pytest.mark.parametrize( + "name", + [ + "", + " ", + "/plan", + "../plan", + "a/b", + "a/../b", + "C:", + ], + ) + def test_rejects_unsafe_names(self, name): + with pytest.raises(ValueError): + validate_skill_name(name) + + def test_rejects_non_string(self): + with pytest.raises(ValueError): + validate_skill_name(123) # type: ignore[arg-type] + + +class TestValidateCategoryName: + + def test_valid_single_segment_category(self): + assert validate_category_name("hub") == "hub" + + def test_rejects_nested_category(self): + with pytest.raises(ValueError): + validate_category_name("hub/sub") + + def test_rejects_traversal(self): + with pytest.raises(ValueError): + validate_category_name("..") + + +class TestValidateBundleRelPath: + + def test_allows_nested_paths(self): + assert validate_bundle_rel_path("scripts/run.sh") == "scripts/run.sh" + + def test_allows_single_segment(self): + assert validate_bundle_rel_path("SKILL.md") == "SKILL.md" + + def test_normalizes_backslashes_to_forward_slashes(self): + assert validate_bundle_rel_path("scripts\\run.sh") == "scripts/run.sh" + + def test_collapses_dot_segments(self): + assert validate_bundle_rel_path("scripts/./run.sh") == "scripts/run.sh" + + @pytest.mark.parametrize( + "rel_path", + [ + "", + " ", + "/etc/passwd", + "../secret", + "scripts/../../secret", + "C:/Windows/System32", + ], + ) + def test_rejects_unsafe_paths(self, rel_path): + with pytest.raises(ValueError): + validate_bundle_rel_path(rel_path) + + def test_rejects_non_string(self): + with pytest.raises(ValueError): + validate_bundle_rel_path(None) # type: ignore[arg-type] diff --git a/tests/skills/hub/test_well_known.py b/tests/skills/hub/test_well_known.py new file mode 100644 index 000000000..706ac5e85 --- /dev/null +++ b/tests/skills/hub/test_well_known.py @@ -0,0 +1,203 @@ +# 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 trpc_agent_sdk.skills.hub._well_known. + +Covers: +- source_id +- _query_to_index_url: URL normalization for different query shapes +- _parse_identifier: index.json#fragment, .../SKILL.md, and bare skill URLs +- search / inspect / fetch against a mocked index + skill files +- fetch rejects unsafe skill names / file paths advertised by a malicious index +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills.hub._well_known import WellKnownSkillSource + + +class TestSourceId: + + def test_source_id(self): + assert WellKnownSkillSource().source_id() == "well-known" + + +class TestBasePathNormalization: + + def test_default_base_path(self): + source = WellKnownSkillSource() + assert source._base_path == "/.well-known/skills" + + def test_custom_base_path_gets_leading_slash(self): + source = WellKnownSkillSource("custom/skills") + assert source._base_path == "/custom/skills" + + def test_trailing_slash_stripped(self): + source = WellKnownSkillSource("/custom/skills/") + assert source._base_path == "/custom/skills" + + +class TestQueryToIndexUrl: + + def test_rejects_non_http_query(self): + source = WellKnownSkillSource() + assert source._query_to_index_url("not-a-url") is None + + def test_query_ending_in_index_json_is_passthrough(self): + source = WellKnownSkillSource() + url = "https://example.com/.well-known/skills/index.json" + assert source._query_to_index_url(url) == url + + def test_query_with_base_path_segment(self): + source = WellKnownSkillSource() + url = "https://example.com/.well-known/skills/plan" + assert source._query_to_index_url(url) == "https://example.com/.well-known/skills/index.json" + + def test_bare_domain_appends_default_index_path(self): + source = WellKnownSkillSource() + assert source._query_to_index_url("https://example.com") == ( + "https://example.com/.well-known/skills/index.json" + ) + + +class TestParseIdentifier: + + def test_rejects_non_http_identifier(self): + source = WellKnownSkillSource() + assert source._parse_identifier("not-a-url") is None + + def test_index_json_with_fragment(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier( + "well-known:https://example.com/.well-known/skills/index.json#plan" + ) + assert parsed == { + "index_url": "https://example.com/.well-known/skills/index.json", + "base_url": "https://example.com/.well-known/skills", + "skill_name": "plan", + "skill_url": "https://example.com/.well-known/skills/plan", + } + + def test_index_json_without_fragment_is_rejected(self): + source = WellKnownSkillSource() + assert source._parse_identifier("https://example.com/.well-known/skills/index.json") is None + + def test_skill_md_url(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier("https://example.com/.well-known/skills/plan/SKILL.md") + assert parsed["skill_name"] == "plan" + assert parsed["base_url"] == "https://example.com/.well-known/skills" + + def test_bare_skill_url(self): + source = WellKnownSkillSource() + parsed = source._parse_identifier("https://example.com/.well-known/skills/plan") + assert parsed["skill_name"] == "plan" + + def test_url_missing_base_path_is_rejected(self): + source = WellKnownSkillSource() + assert source._parse_identifier("https://example.com/plan") is None + + +class TestSearch: + + def test_non_url_query_returns_empty(self): + source = WellKnownSkillSource() + assert source.search("plan") == [] + + def test_returns_metas_from_index(self): + source = WellKnownSkillSource() + index = { + "index_url": "https://example.com/.well-known/skills/index.json", + "base_url": "https://example.com/.well-known/skills", + "skills": [{"name": "plan", "description": "Plan things", "files": ["SKILL.md"]}], + } + with patch.object(source, "_parse_index", return_value=index): + results = source.search("https://example.com/.well-known/skills/index.json") + assert len(results) == 1 + assert results[0].name == "plan" + assert results[0].identifier == "well-known:https://example.com/.well-known/skills/plan" + + def test_index_fetch_failure_returns_empty(self): + source = WellKnownSkillSource() + with patch.object(source, "_parse_index", return_value=None): + assert source.search("https://example.com") == [] + + +class TestInspect: + + def test_returns_none_for_unparseable_identifier(self): + source = WellKnownSkillSource() + assert source.inspect("not-a-url") is None + + def test_builds_meta_from_entry_and_skill_md(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "description": "fallback desc", "files": ["SKILL.md"]} + skill_md = "---\nname: plan\ndescription: real desc\n---\nbody" + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", return_value=skill_md): + meta = source.inspect("https://example.com/.well-known/skills/plan") + assert meta.name == "plan" + assert meta.description == "real desc" + assert meta.source == "well-known" + + def test_returns_none_when_entry_missing(self): + source = WellKnownSkillSource() + with patch.object(source, "_index_entry", return_value=None): + assert source.inspect("https://example.com/.well-known/skills/plan") is None + + def test_returns_none_when_skill_md_fetch_fails(self): + source = WellKnownSkillSource() + with patch.object(source, "_index_entry", return_value={"name": "plan"}), \ + patch.object(source, "_fetch_text", return_value=None): + assert source.inspect("https://example.com/.well-known/skills/plan") is None + + +class TestFetch: + + def test_returns_none_for_unparseable_identifier(self): + source = WellKnownSkillSource() + assert source.fetch("not-a-url") is None + + def test_downloads_all_declared_files(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["SKILL.md", "scripts/run.sh"]} + texts = { + "https://example.com/.well-known/skills/plan/SKILL.md": "---\nname: plan\n---\nbody", + "https://example.com/.well-known/skills/plan/scripts/run.sh": "echo hi", + } + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", side_effect=lambda url: texts[url]): + bundle = source.fetch("https://example.com/.well-known/skills/plan") + assert bundle is not None + assert bundle.name == "plan" + assert set(bundle.files) == {"SKILL.md", "scripts/run.sh"} + + def test_rejects_unsafe_file_path_from_index(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["../../etc/passwd"]} + with patch.object(source, "_index_entry", return_value=entry): + assert source.fetch("https://example.com/.well-known/skills/plan") is None + + def test_missing_skill_md_in_downloaded_files_returns_none(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["other.txt"]} + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", return_value="content"): + assert source.fetch("https://example.com/.well-known/skills/plan") is None + + def test_returns_none_when_a_file_fetch_fails(self): + source = WellKnownSkillSource() + entry = {"name": "plan", "files": ["SKILL.md", "missing.txt"]} + + def fake_fetch_text(url): + return None if "missing.txt" in url else "---\nname: plan\n---\nbody" + + with patch.object(source, "_index_entry", return_value=entry), \ + patch.object(source, "_fetch_text", side_effect=fake_fetch_text): + assert source.fetch("https://example.com/.well-known/skills/plan") is None diff --git a/tests/skills/stager/__init__.py b/tests/skills/stager/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/skills/stager/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/skills/stager/test_base_stager.py b/tests/skills/stager/test_base_stager.py new file mode 100644 index 000000000..bb2f0dc88 --- /dev/null +++ b/tests/skills/stager/test_base_stager.py @@ -0,0 +1,318 @@ +# 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 trpc_agent_sdk.skills.stager._base_stager. + +Covers: +- Stager.stage_skill: fresh staging, cached (digest match), re-staging +- Stager.load_workspace_metadata / save_workspace_metadata +- Stager.skill_links_present +- Stager.remove_workspace_path +- Stager.create_stager factory +""" + +from __future__ import annotations + +import json +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from trpc_agent_sdk.skills.stager._base_stager import Stager +from trpc_agent_sdk.skills._types import SkillWorkspaceMetadata, SkillMetadata + + +def _make_ctx(): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.session_state = {} + return ctx + + +def _make_workspace(path="/tmp/ws"): + ws = MagicMock() + ws.path = path + return ws + + +def _make_runtime(fs_collect_return=None, runner_exit_code=0): + runtime = MagicMock() + fs = MagicMock() + runner = MagicMock() + + if fs_collect_return is not None: + fs.collect = AsyncMock(return_value=fs_collect_return) + else: + fs.collect = AsyncMock(return_value=[]) + fs.stage_directory = AsyncMock() + fs.put_files = AsyncMock() + + run_result = MagicMock() + run_result.exit_code = runner_exit_code + run_result.stderr = "" + runner.run_program = AsyncMock(return_value=run_result) + + runtime.fs = MagicMock(return_value=fs) + runtime.runner = MagicMock(return_value=runner) + return runtime + + +def _make_repository(path="/skills/test-skill"): + repo = MagicMock() + repo.path = MagicMock(return_value=path) + repo.workspace_runtime = _make_runtime() + repo.get_workspace_runtime = MagicMock(return_value=repo.workspace_runtime) + return repo + + +def _make_request(skill_name="test-skill", repo=None, ws=None, ctx=None): + from trpc_agent_sdk.skills.stager._types import SkillStageRequest + return SkillStageRequest( + skill_name=skill_name, + repository=repo or _make_repository(), + workspace=ws or _make_workspace(), + ctx=ctx or _make_ctx(), + ) + + +# --------------------------------------------------------------------------- +# create_stager +# --------------------------------------------------------------------------- + +class TestCreateStager: + def test_creates_instance(self): + stager = Stager.create_stager() + assert isinstance(stager, Stager) + + +# --------------------------------------------------------------------------- +# load_workspace_metadata +# --------------------------------------------------------------------------- + +class TestLoadWorkspaceMetadata: + async def test_empty_file_returns_default(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime(fs_collect_return=[]) + ws = _make_workspace() + md = await stager.load_workspace_metadata(ctx, runtime, ws) + assert isinstance(md, SkillWorkspaceMetadata) + assert md.version == 1 + + async def test_valid_json_parsed(self): + stager = Stager() + ctx = _make_ctx() + ws = _make_workspace() + + mock_file = MagicMock() + md_data = { + "version": 2, + "skills": {"s1": {"name": "s1", "digest": "d1", "mounted": True}}, + } + mock_file.content = json.dumps(md_data) + runtime = _make_runtime(fs_collect_return=[mock_file]) + + md = await stager.load_workspace_metadata(ctx, runtime, ws) + assert md.version == 2 + assert "s1" in md.skills + + async def test_invalid_json_returns_default(self): + stager = Stager() + ctx = _make_ctx() + ws = _make_workspace() + + mock_file = MagicMock() + mock_file.content = "not json" + runtime = _make_runtime(fs_collect_return=[mock_file]) + + md = await stager.load_workspace_metadata(ctx, runtime, ws) + assert md.version == 1 + + async def test_empty_content_returns_default(self): + stager = Stager() + ctx = _make_ctx() + ws = _make_workspace() + + mock_file = MagicMock() + mock_file.content = " " + runtime = _make_runtime(fs_collect_return=[mock_file]) + + md = await stager.load_workspace_metadata(ctx, runtime, ws) + assert md.version == 1 + + +# --------------------------------------------------------------------------- +# save_workspace_metadata +# --------------------------------------------------------------------------- + +class TestSaveWorkspaceMetadata: + async def test_saves_metadata(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + md = SkillWorkspaceMetadata(version=3) + + await stager.save_workspace_metadata(ctx, runtime, ws, md) + + runtime.fs(ctx).put_files.assert_called_once() + runtime.runner(ctx).run_program.assert_called_once() + + async def test_sets_version_if_missing(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + md = SkillWorkspaceMetadata() + md.version = 0 + + await stager.save_workspace_metadata(ctx, runtime, ws, md) + assert md.version == 1 + + async def test_sets_timestamps(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + md = SkillWorkspaceMetadata(version=1) + + await stager.save_workspace_metadata(ctx, runtime, ws, md) + assert md.updated_at is not None + assert md.last_access is not None + assert md.created_at is not None + + +# --------------------------------------------------------------------------- +# skill_links_present +# --------------------------------------------------------------------------- + +class TestSkillLinksPresent: + async def test_links_present(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime(runner_exit_code=0) + ws = _make_workspace() + result = await stager.skill_links_present(ctx, runtime, ws, "test") + assert result is True + + async def test_links_missing(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime(runner_exit_code=1) + ws = _make_workspace() + result = await stager.skill_links_present(ctx, runtime, ws, "test") + assert result is False + + async def test_empty_name_returns_false(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + result = await stager.skill_links_present(ctx, runtime, ws, "") + assert result is False + + async def test_whitespace_name_returns_false(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + result = await stager.skill_links_present(ctx, runtime, ws, " ") + assert result is False + + +# --------------------------------------------------------------------------- +# remove_workspace_path +# --------------------------------------------------------------------------- + +class TestRemoveWorkspacePath: + async def test_removes_path(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + await stager.remove_workspace_path(ctx, runtime, ws, "skills/test") + runtime.runner(ctx).run_program.assert_called_once() + + async def test_empty_path_is_noop(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + await stager.remove_workspace_path(ctx, runtime, ws, "") + runtime.runner(ctx).run_program.assert_not_called() + + async def test_whitespace_path_is_noop(self): + stager = Stager() + ctx = _make_ctx() + runtime = _make_runtime() + ws = _make_workspace() + await stager.remove_workspace_path(ctx, runtime, ws, " ") + runtime.runner(ctx).run_program.assert_not_called() + + +# --------------------------------------------------------------------------- +# stage_skill +# --------------------------------------------------------------------------- + +class TestStageSkill: + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="new_digest") + async def test_fresh_staging(self, mock_digest): + stager = Stager() + request = _make_request() + runtime = request.repository.workspace_runtime + + mock_file = MagicMock() + mock_file.content = json.dumps({"version": 1, "skills": {}}) + runtime.fs(request.ctx).collect = AsyncMock(return_value=[mock_file]) + + result = await stager.stage_skill(request) + assert result.workspace_skill_dir == "skills/test-skill" + + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="new_digest") + async def test_stage_skill_uses_repository_runtime(self, mock_digest): + stager = Stager() + repo = _make_repository() + request = _make_request(repo=repo) + runtime = repo.get_workspace_runtime.return_value + + mock_file = MagicMock() + mock_file.content = json.dumps({"version": 1, "skills": {}}) + runtime.fs(request.ctx).collect = AsyncMock(return_value=[mock_file]) + + result = await stager.stage_skill(request) + + assert result.workspace_skill_dir == "skills/test-skill" + repo.get_workspace_runtime.assert_called_once_with(request.ctx) + runtime.fs(request.ctx).stage_directory.assert_awaited_once() + + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="same_digest") + async def test_cached_staging_with_links(self, mock_digest): + stager = Stager() + request = _make_request() + runtime = request.repository.workspace_runtime + + md_data = { + "version": 1, + "skills": { + "test-skill": { + "name": "test-skill", + "digest": "same_digest", + "mounted": True, + }, + }, + } + mock_file = MagicMock() + mock_file.content = json.dumps(md_data) + runtime.fs(request.ctx).collect = AsyncMock(return_value=[mock_file]) + + # Make links present + run_result = MagicMock() + run_result.exit_code = 0 + run_result.stderr = "" + runtime.runner(request.ctx).run_program = AsyncMock(return_value=run_result) + + result = await stager.stage_skill(request) + assert result.workspace_skill_dir == "skills/test-skill" diff --git a/tests/skills/stager/test_types.py b/tests/skills/stager/test_types.py new file mode 100644 index 000000000..b269c9c13 --- /dev/null +++ b/tests/skills/stager/test_types.py @@ -0,0 +1,45 @@ +# 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 trpc_agent_sdk.skills.stager._types. + +Covers: +- SkillStageRequest dataclass fields +- SkillStageResult dataclass fields +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from trpc_agent_sdk.skills.stager._types import SkillStageRequest, SkillStageResult + + +class TestSkillStageRequest: + def test_creation(self): + req = SkillStageRequest( + skill_name="test", + repository=MagicMock(), + workspace=MagicMock(), + ctx=MagicMock(), + ) + assert req.skill_name == "test" + assert req.timeout == 300.0 + + def test_custom_timeout(self): + req = SkillStageRequest( + skill_name="test", + repository=MagicMock(), + workspace=MagicMock(), + ctx=MagicMock(), + timeout=60.0, + ) + assert req.timeout == 60.0 + + +class TestSkillStageResult: + def test_creation(self): + result = SkillStageResult(workspace_skill_dir="skills/test") + assert result.workspace_skill_dir == "skills/test" diff --git a/tests/skills/stager/test_utils.py b/tests/skills/stager/test_utils.py new file mode 100644 index 000000000..dd15bfc5f --- /dev/null +++ b/tests/skills/stager/test_utils.py @@ -0,0 +1,28 @@ +# 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 trpc_agent_sdk.skills.stager._utils. + +Covers: +- default_workspace_skill_dir +""" + +from __future__ import annotations + +from trpc_agent_sdk.skills.stager._utils import default_workspace_skill_dir + + +class TestDefaultWorkspaceSkillDir: + def test_basic(self): + result = default_workspace_skill_dir("weather") + assert result == "skills/weather" + + def test_with_dashes(self): + result = default_workspace_skill_dir("my-complex-skill") + assert result == "skills/my-complex-skill" + + def test_empty_name(self): + result = default_workspace_skill_dir("") + assert result == "skills/" diff --git a/tests/skills/test_common.py b/tests/skills/test_common.py new file mode 100644 index 000000000..221c7f67a --- /dev/null +++ b/tests/skills/test_common.py @@ -0,0 +1,532 @@ +# 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. + +import pytest +import json +from unittest.mock import Mock +from pydantic import Field + +from trpc_agent_sdk.skills._common import ( + get_state_delta_value, + SelectionMode, + BaseSelectionResult, + append_loaded_order_state_delta, + get_previous_selection, + clear_selection, + add_selection, + replace_selection, + set_state_delta_for_selection, + generic_select_items, + generic_get_selection, +) +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.skills._state_keys import loaded_order_key + + +class MockSelectionResult(BaseSelectionResult): + """Mock selection result class for testing.""" + selected_items: list[str] = Field(default_factory=list) + include_all: bool = Field(default=False) + + +@pytest.fixture(autouse=True) +def _mock_turn_load_mode(monkeypatch): + monkeypatch.setattr("trpc_agent_sdk.skills._common.get_skill_load_mode", lambda _ctx: "turn") + + +class TestGetStateDeltaValue: + """Test suite for get_state_delta_value function.""" + + def test_get_from_state_delta(self): + """Test getting value from state_delta.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {"key1": "value1"} + mock_ctx.session_state = {"key1": "old_value"} + + result = get_state_delta_value(mock_ctx, "key1") + + assert result == "value1" + + def test_get_from_session_state(self): + """Test getting value from session_state when not in state_delta.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"key1": "value1"} + + result = get_state_delta_value(mock_ctx, "key1") + + assert result == "value1" + + def test_get_nonexistent_key(self): + """Test getting nonexistent key returns None.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {} + + result = get_state_delta_value(mock_ctx, "nonexistent") + + assert result is None + + +class TestSelectionMode: + """Test suite for SelectionMode enum.""" + + def test_selection_mode_values(self): + """Test SelectionMode enum values.""" + assert SelectionMode.ADD.value == "add" + assert SelectionMode.REPLACE.value == "replace" + assert SelectionMode.CLEAR.value == "clear" + + def test_selection_mode_from_string(self): + """Test creating SelectionMode from string.""" + assert SelectionMode("add") == SelectionMode.ADD + assert SelectionMode("replace") == SelectionMode.REPLACE + assert SelectionMode("clear") == SelectionMode.CLEAR + + def test_selection_mode_invalid_string(self): + """Test invalid string raises ValueError.""" + with pytest.raises(ValueError): + SelectionMode("invalid") + + +class TestBaseSelectionResult: + """Test suite for BaseSelectionResult class.""" + + def test_create_base_selection_result(self): + """Test creating BaseSelectionResult.""" + result = BaseSelectionResult(skill="test-skill", mode="replace") + + assert result.skill == "test-skill" + assert result.mode == "replace" + + def test_default_mode(self): + """Test default mode is empty string.""" + result = BaseSelectionResult(skill="test-skill") + + assert result.mode == "" + + +class TestGetPreviousSelection: + """Test suite for get_previous_selection function.""" + + def test_get_previous_selection_json(self): + """Test getting previous selection from JSON.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:docs:test-skill": json.dumps(["doc1.md", "doc2.md"])} + + result = get_previous_selection(mock_ctx, "temp:skill:docs:", "test-skill") + + assert result == ["doc1.md", "doc2.md"] + + def test_get_previous_selection_all(self): + """Test getting previous selection when all items selected.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:docs:test-skill": '*'} + + result = get_previous_selection(mock_ctx, "temp:skill:docs:", "test-skill") + + assert result is None + + def test_get_previous_selection_not_found(self): + """Test getting previous selection when not found.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {} + + result = get_previous_selection(mock_ctx, "temp:skill:docs:", "test-skill") + + assert result == [] + + +class TestAppendLoadedOrderStateDelta: + """Test suite for append_loaded_order_state_delta function.""" + + def test_appends_to_temp_order_when_temp_exists(self): + mock_ctx = Mock(spec=InvocationContext) + temp_key = loaded_order_key("demo-agent") + mock_ctx.session_state = {temp_key: json.dumps(["skill-a"])} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.agent_context = Mock() + mock_ctx.agent_context.get_metadata = Mock(side_effect=lambda _key, default=None: default) + + append_loaded_order_state_delta(mock_ctx, "demo-agent", "skill-b") + + assert mock_ctx.actions.state_delta[temp_key] == json.dumps(["skill-a", "skill-b"]) + + def test_initializes_order_when_missing(self): + mock_ctx = Mock(spec=InvocationContext) + temp_key = loaded_order_key("demo-agent") + mock_ctx.session_state = {} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.agent_context = Mock() + mock_ctx.agent_context.get_metadata = Mock(side_effect=lambda _key, default=None: default) + + append_loaded_order_state_delta(mock_ctx, "demo-agent", "skill-b") + + assert mock_ctx.actions.state_delta[temp_key] == json.dumps(["skill-b"]) + + def test_get_previous_selection_invalid_json(self): + """Test getting previous selection with invalid JSON.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:docs:test-skill": "invalid json"} + + result = get_previous_selection(mock_ctx, "temp:skill:docs:", "test-skill") + + assert result == [] + + +class TestClearSelection: + """Test suite for clear_selection function.""" + + def test_clear_selection(self): + """Test clearing selection.""" + result = clear_selection(skill_name="test-skill", + items=["item1"], + include_all=False, + previous_items=["item1", "item2"], + result_class=MockSelectionResult) + + assert isinstance(result, MockSelectionResult) + assert result.skill == "test-skill" + assert result.mode == "clear" + assert result.selected_items == [] + assert result.include_all is False + + +class TestAddSelection: + """Test suite for add_selection function.""" + + def test_add_selection(self): + """Test adding to selection.""" + result = add_selection(skill_name="test-skill", + items=["item2", "item3"], + include_all=False, + previous_items=["item1"], + result_class=MockSelectionResult) + + assert isinstance(result, MockSelectionResult) + assert result.skill == "test-skill" + assert result.mode == "add" + assert set(result.selected_items) == {"item1", "item2", "item3"} + assert result.include_all is False + + def test_add_selection_duplicates(self): + """Test adding selection removes duplicates.""" + result = add_selection(skill_name="test-skill", + items=["item1", "item2"], + include_all=False, + previous_items=["item1"], + result_class=MockSelectionResult) + + assert len(result.selected_items) == 2 + assert result.selected_items.count("item1") == 1 + + def test_add_selection_include_all(self): + """Test adding selection with include_all.""" + result = add_selection(skill_name="test-skill", + items=["item2"], + include_all=True, + previous_items=["item1"], + result_class=MockSelectionResult) + + assert result.selected_items == [] + assert result.include_all is True + + +class TestReplaceSelection: + """Test suite for replace_selection function.""" + + def test_replace_selection(self): + """Test replacing selection.""" + result = replace_selection(skill_name="test-skill", + items=["item2", "item3"], + include_all=False, + previous_items=["item1"], + result_class=MockSelectionResult) + + assert isinstance(result, MockSelectionResult) + assert result.skill == "test-skill" + assert result.mode == "replace" + assert result.selected_items == ["item2", "item3"] + assert result.include_all is False + + def test_replace_selection_include_all(self): + """Test replacing selection with include_all.""" + result = replace_selection(skill_name="test-skill", + items=["item2"], + include_all=True, + previous_items=["item1"], + result_class=MockSelectionResult) + + assert result.selected_items == [] + assert result.include_all is True + + +class TestSetStateDeltaForSelection: + """Test suite for set_state_delta_for_selection function.""" + + def test_set_state_delta_with_items(self): + """Test setting state delta with items.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = MockSelectionResult(skill="test-skill", + selected_items=["item1", "item2"], + include_all=False, + mode="replace") + + set_state_delta_for_selection(mock_ctx, "temp:skill:test:", result) + + key = "temp:skill:test:test-skill" + assert key in mock_ctx.actions.state_delta + assert json.loads(mock_ctx.actions.state_delta[key]) == ["item1", "item2"] + + def test_set_state_delta_include_all(self): + """Test setting state delta with include_all.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = MockSelectionResult(skill="test-skill", selected_items=[], include_all=True, mode="replace") + + set_state_delta_for_selection(mock_ctx, "temp:skill:test:", result) + + key = "temp:skill:test:test-skill" + assert mock_ctx.actions.state_delta[key] == '*' + + def test_set_state_delta_empty_skill(self): + """Test setting state delta with empty skill name does nothing.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = MockSelectionResult(skill="", selected_items=["item1"], include_all=False, mode="replace") + + set_state_delta_for_selection(mock_ctx, "temp:skill:test:", result) + + assert len(mock_ctx.actions.state_delta) == 0 + + +class TestGenericSelectItems: + """Test suite for generic_select_items function.""" + + def test_generic_select_items_replace(self): + """Test generic select items with replace mode.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=["item1", "item2"], + include_all=False, + mode="replace", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + assert isinstance(result, MockSelectionResult) + assert result.skill == "test-skill" + assert result.mode == "replace" + assert result.selected_items == ["item1", "item2"] + + def test_generic_select_items_add(self): + """Test generic select items with add mode.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:test:test-skill": json.dumps(["item1"])} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=["item2"], + include_all=False, + mode="add", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + assert result.mode == "add" + assert set(result.selected_items) == {"item1", "item2"} + + def test_generic_select_items_clear(self): + """Test generic select items with clear mode.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:test:test-skill": json.dumps(["item1"])} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=None, + include_all=False, + mode="clear", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + assert result.mode == "clear" + assert result.selected_items == [] + + def test_generic_select_items_invalid_mode(self): + """Test generic select items with invalid mode defaults to replace.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=["item1"], + include_all=False, + mode="invalid", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + assert result.mode == "replace" + + def test_generic_select_items_previous_all(self): + """Test generic select items when previous was all.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {"temp:skill:test:test-skill": '*'} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=["item1"], + include_all=False, + mode="add", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + # Should maintain include_all=True + assert result.include_all is True + + def test_generic_select_items_updates_state(self): + """Test generic select items updates state delta.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.session_state = {} + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + + result = generic_select_items(tool_context=mock_ctx, + skill_name="test-skill", + items=["item1"], + include_all=False, + mode="replace", + state_key_prefix="temp:skill:test:", + result_class=MockSelectionResult) + + key = "temp:skill:test:test-skill" + assert key in mock_ctx.actions.state_delta + + +class TestGenericGetSelection: + """Test suite for generic_get_selection function.""" + + def test_generic_get_selection_json_array(self): + """Test getting selection from JSON array.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": json.dumps(["item1", "item2"])} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == ["item1", "item2"] + + def test_generic_get_selection_all_with_callback(self): + """Test getting selection with '*' and callback.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": '*'} + + def get_all_items(skill_name): + return ["item1", "item2", "item3"] + + result = generic_get_selection(ctx=mock_ctx, + skill_name="test-skill", + state_key_prefix="temp:skill:test:", + get_all_items_callback=get_all_items) + + assert result == ["item1", "item2", "item3"] + + def test_generic_get_selection_all_without_callback(self): + """Test getting selection with '*' but no callback.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": '*'} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == [] + + def test_generic_get_selection_not_found(self): + """Test getting selection when not found.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == [] + + def test_generic_get_selection_invalid_json(self): + """Test getting selection with invalid JSON.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": "invalid json"} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == [] + + def test_generic_get_selection_bytes_value(self): + """Test getting selection when value is bytes.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": json.dumps(["item1"]).encode('utf-8')} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == ["item1"] + + def test_generic_get_selection_callback_exception(self): + """Test getting selection when callback raises exception.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {} + mock_ctx.session_state = {"temp:skill:test:test-skill": '*'} + + def get_all_items_error(skill_name): + raise Exception("Test error") + + result = generic_get_selection(ctx=mock_ctx, + skill_name="test-skill", + state_key_prefix="temp:skill:test:", + get_all_items_callback=get_all_items_error) + + assert result == [] + + def test_generic_get_selection_from_state_delta(self): + """Test getting selection prefers state_delta over session_state.""" + mock_ctx = Mock(spec=InvocationContext) + mock_ctx.actions = Mock() + mock_ctx.actions.state_delta = {"temp:skill:test:test-skill": json.dumps(["item_new"])} + mock_ctx.session_state = {"temp:skill:test:test-skill": json.dumps(["item_old"])} + + result = generic_get_selection(ctx=mock_ctx, skill_name="test-skill", state_key_prefix="temp:skill:test:") + + assert result == ["item_new"] diff --git a/tests/skills/test_constants.py b/tests/skills/test_constants.py new file mode 100644 index 000000000..26b80fac7 --- /dev/null +++ b/tests/skills/test_constants.py @@ -0,0 +1,23 @@ +# 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. + +from trpc_agent_sdk.skills._constants import SKILL_FILE +from trpc_agent_sdk.skills._constants import SKILL_LOAD_MODE_VALUES +from trpc_agent_sdk.skills._constants import SKILL_TOOLS_NAMES +from trpc_agent_sdk.skills._constants import SkillLoadModeNames +from trpc_agent_sdk.skills._constants import SkillToolsNames + + +def test_skill_file_constant(): + assert SKILL_FILE == "SKILL.md" + + +def test_skill_tools_names_matches_enum(): + assert SKILL_TOOLS_NAMES == [item.value for item in SkillToolsNames] + + +def test_skill_load_mode_values_matches_enum(): + assert SKILL_LOAD_MODE_VALUES == [item.value for item in SkillLoadModeNames] diff --git a/tests/skills/test_dynamic_toolset.py b/tests/skills/test_dynamic_toolset.py new file mode 100644 index 000000000..df65fb81b --- /dev/null +++ b/tests/skills/test_dynamic_toolset.py @@ -0,0 +1,473 @@ +# 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 trpc_agent_sdk.skills._dynamic_toolset. + +Covers: +- DynamicSkillToolSet initialization (tools, toolsets, string names) +- _find_tool_by_name / _find_tool_by_type +- _resolve_tool: cache, available_tools, toolsets, global registry +- get_tools: active skills, loaded skills, tool selection +- _get_loaded_skills_from_state / _get_active_skills_from_delta +- _get_tools_selection: JSON, star, fallback to defaults +- _get_skill_default_tools +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from trpc_agent_sdk.skills._dynamic_toolset import DynamicSkillToolSet +from trpc_agent_sdk.skills._common import loaded_state_key +from trpc_agent_sdk.skills._common import tool_state_key +from trpc_agent_sdk.skills._constants import SKILL_CONFIG_KEY +from trpc_agent_sdk.skills._skill_config import DEFAULT_SKILL_CONFIG + + +def _make_ctx(state_delta=None, session_state=None): + ctx = MagicMock() + ctx.actions.state_delta = state_delta or {} + ctx.session_state = session_state or {} + ctx.agent_name = "" + ctx.agent_context.get_metadata = MagicMock( + side_effect=lambda key, default=None: DEFAULT_SKILL_CONFIG if key == SKILL_CONFIG_KEY else default) + return ctx + + +def _make_mock_tool(name: str): + tool = MagicMock() + tool.name = name + return tool + + +def _make_mock_toolset(name: str, tools=None): + toolset = MagicMock() + toolset.name = name + toolset.get_tools = AsyncMock(return_value=tools or []) + return toolset + + +def _make_mock_repository(skills=None): + repo = MagicMock() + skills = skills or {} + def _get(name): + if name in skills: + return skills[name] + raise ValueError(f"skill '{name}' not found") + repo.get = _get + return repo + + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +class TestDynamicSkillToolSetInit: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_init_no_tools(self, mock_get_tool_set, mock_get_tool): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + assert ts._only_active_skills is True + assert len(ts._available_tools) == 0 + + def test_init_with_tool_instances(self): + repo = _make_mock_repository() + tool = _make_mock_tool("my_tool") + # BaseTool check + with patch("trpc_agent_sdk.skills._dynamic_toolset.isinstance", side_effect=isinstance): + ts = DynamicSkillToolSet(skill_repository=repo, available_tools=[tool]) + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool") + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set") + def test_init_with_string_tool_found(self, mock_get_tool_set, mock_get_tool): + repo = _make_mock_repository() + mock_tool = _make_mock_tool("found_tool") + mock_get_tool.return_value = mock_tool + ts = DynamicSkillToolSet(skill_repository=repo, available_tools=["found_tool"]) + assert "found_tool" in ts._available_tools + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set") + def test_init_with_string_toolset_found(self, mock_get_tool_set, mock_get_tool): + repo = _make_mock_repository() + mock_ts = _make_mock_toolset("found_toolset") + mock_get_tool_set.return_value = mock_ts + ts = DynamicSkillToolSet(skill_repository=repo, available_tools=["found_toolset"]) + assert len(ts._available_toolsets) == 1 + + +# --------------------------------------------------------------------------- +# _get_loaded_skills_from_state +# --------------------------------------------------------------------------- + +class TestGetLoadedSkillsFromState: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_no_loaded_skills(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx() + assert ts._get_loaded_skills_from_state(ctx) == [] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_loaded_skills_from_session_and_delta(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx() + ctx.session_state = {loaded_state_key(ctx, "skill-a"): True} + ctx.actions.state_delta = {loaded_state_key(ctx, "skill-b"): True} + result = ts._get_loaded_skills_from_state(ctx) + assert set(result) == {"skill-a", "skill-b"} + + +# --------------------------------------------------------------------------- +# _get_active_skills_from_delta +# --------------------------------------------------------------------------- + +class TestGetActiveSkillsFromDelta: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_no_active_skills(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx() + assert ts._get_active_skills_from_delta(ctx) == [] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_active_from_loaded(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={loaded_state_key(_make_ctx(), "s1"): True}) + result = ts._get_active_skills_from_delta(ctx) + assert "s1" in result + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_active_from_tools_modified(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s2"): json.dumps(["t1"])}) + result = ts._get_active_skills_from_delta(ctx) + assert "s2" in result + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_falsy_loaded_value_ignored(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={loaded_state_key(_make_ctx(), "s1"): False}) + assert ts._get_active_skills_from_delta(ctx) == [] + + +# --------------------------------------------------------------------------- +# _get_tools_selection +# --------------------------------------------------------------------------- + +class TestGetToolsSelection: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_json_array(self, *_): + skill = MagicMock() + skill.tools = ["default_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): json.dumps(["tool_a", "tool_b"])}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["tool_a", "tool_b"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_star_returns_defaults(self, *_): + skill = MagicMock() + skill.tools = ["default_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): "*"}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["default_tool"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_no_selection_falls_back_to_defaults(self, *_): + skill = MagicMock() + skill.tools = ["fallback_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx() + result = ts._get_tools_selection(ctx, "s1") + assert result == ["fallback_tool"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_invalid_json_falls_back(self, *_): + skill = MagicMock() + skill.tools = ["fallback"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): "not_json"}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["fallback"] + + +# --------------------------------------------------------------------------- +# _get_skill_default_tools +# --------------------------------------------------------------------------- + +class TestGetSkillDefaultTools: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_returns_skill_tools(self, *_): + skill = MagicMock() + skill.tools = ["t1", "t2"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + assert ts._get_skill_default_tools("s1") == ["t1", "t2"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_skill_not_found_returns_empty(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + assert ts._get_skill_default_tools("nonexistent") == [] + + +# --------------------------------------------------------------------------- +# _resolve_tool +# --------------------------------------------------------------------------- + +class TestResolveTool: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_from_available_tools(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + mock_tool = _make_mock_tool("my_tool") + ts._available_tools["my_tool"] = mock_tool + result = await ts._resolve_tool("my_tool") + assert result is mock_tool + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_from_cache(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + mock_tool = _make_mock_tool("cached_tool") + ts._tool_cache["cached_tool"] = mock_tool + result = await ts._resolve_tool("cached_tool") + assert result is mock_tool + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_not_found_returns_none(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + result = await ts._resolve_tool("nonexistent") + assert result is None + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool") + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_from_global_registry(self, _, mock_get_tool): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + global_tool = _make_mock_tool("global_tool") + mock_get_tool.return_value = global_tool + result = await ts._resolve_tool("global_tool") + assert result is global_tool + + +# --------------------------------------------------------------------------- +# get_tools +# --------------------------------------------------------------------------- + +class TestGetTools: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_no_skills_returns_empty(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx() + result = await ts.get_tools(ctx) + assert result == [] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_active_skills_with_tools(self, *_): + skill = MagicMock() + skill.tools = ["my_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + mock_tool = _make_mock_tool("my_tool") + ts._available_tools["my_tool"] = mock_tool + + ctx = _make_ctx(state_delta={loaded_state_key(_make_ctx(), "s1"): True}) + result = await ts.get_tools(ctx) + assert len(result) == 1 + assert result[0] is mock_tool + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_only_active_false_uses_all_loaded(self, *_): + skill = MagicMock() + skill.tools = ["tool_a"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo, only_active_skills=False) + mock_tool = _make_mock_tool("tool_a") + ts._available_tools["tool_a"] = mock_tool + + ctx = _make_ctx(session_state={loaded_state_key(_make_ctx(), "s1"): True}) + result = await ts.get_tools(ctx) + assert len(result) == 1 + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_deduplicates_tools(self, *_): + skill_a = MagicMock() + skill_a.tools = ["shared_tool"] + skill_b = MagicMock() + skill_b.tools = ["shared_tool"] + repo = _make_mock_repository({"s1": skill_a, "s2": skill_b}) + ts = DynamicSkillToolSet(skill_repository=repo, only_active_skills=False) + mock_tool = _make_mock_tool("shared_tool") + ts._available_tools["shared_tool"] = mock_tool + + key_ctx = _make_ctx() + ctx = _make_ctx(session_state={ + loaded_state_key(key_ctx, "s1"): True, + loaded_state_key(key_ctx, "s2"): True, + }) + result = await ts.get_tools(ctx) + assert len(result) == 1 + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_fallback_to_loaded_when_no_active(self, *_): + skill = MagicMock() + skill.tools = ["fallback_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo, only_active_skills=True) + mock_tool = _make_mock_tool("fallback_tool") + ts._available_tools["fallback_tool"] = mock_tool + + ctx = _make_ctx(session_state={loaded_state_key(_make_ctx(), "s1"): True}) + result = await ts.get_tools(ctx) + assert len(result) == 1 + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_unresolvable_tool_skipped(self, *_): + skill = MagicMock() + skill.tools = ["nonexistent_tool"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={loaded_state_key(_make_ctx(), "s1"): True}) + result = await ts.get_tools(ctx) + assert len(result) == 0 + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_no_tools_for_skill(self, *_): + skill = MagicMock() + skill.tools = [] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={loaded_state_key(_make_ctx(), "s1"): True}) + result = await ts.get_tools(ctx) + assert result == [] + + +# --------------------------------------------------------------------------- +# _find_tool_by_name / _find_tool_by_type +# --------------------------------------------------------------------------- + +class TestFindToolMethods: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_find_by_name_not_found(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + assert ts._find_tool_by_name("missing") is False + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_find_by_type_unknown(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + assert ts._find_tool_by_type("not_a_tool_object") is False + + +# --------------------------------------------------------------------------- +# _resolve_tool from toolsets +# --------------------------------------------------------------------------- + +class TestResolveToolFromToolsets: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_from_toolset(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + mock_tool = _make_mock_tool("ts_tool") + mock_toolset = _make_mock_toolset("my_ts", tools=[mock_tool]) + ts._available_toolsets = [mock_toolset] + + result = await ts._resolve_tool("ts_tool", _make_ctx()) + assert result is mock_tool + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + async def test_resolve_toolset_error(self, *_): + repo = _make_mock_repository() + ts = DynamicSkillToolSet(skill_repository=repo) + bad_toolset = MagicMock() + bad_toolset.name = "bad" + bad_toolset.get_tools = AsyncMock(side_effect=RuntimeError("fail")) + ts._available_toolsets = [bad_toolset] + + result = await ts._resolve_tool("missing", _make_ctx()) + assert result is None + + +# --------------------------------------------------------------------------- +# _get_tools_selection — bytes value +# --------------------------------------------------------------------------- + +class TestGetToolsSelectionBytes: + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_bytes_json_value(self, *_): + skill = MagicMock() + skill.tools = ["default"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): json.dumps(["t1"]).encode()}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["t1"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_bytes_star_value(self, *_): + skill = MagicMock() + skill.tools = ["default"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): b"*"}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["default"] + + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool", return_value=None) + @patch("trpc_agent_sdk.skills._dynamic_toolset.get_tool_set", return_value=None) + def test_non_list_json_falls_back(self, *_): + skill = MagicMock() + skill.tools = ["default"] + repo = _make_mock_repository({"s1": skill}) + ts = DynamicSkillToolSet(skill_repository=repo) + ctx = _make_ctx(state_delta={tool_state_key(_make_ctx(), "s1"): json.dumps({"not": "list"})}) + result = ts._get_tools_selection(ctx, "s1") + assert result == ["default"] diff --git a/tests/skills/test_hot_reload.py b/tests/skills/test_hot_reload.py new file mode 100644 index 000000000..bc8411b01 --- /dev/null +++ b/tests/skills/test_hot_reload.py @@ -0,0 +1,35 @@ +# 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. + +from pathlib import Path + +from trpc_agent_sdk.skills._hot_reload import SkillHotReloadTracker + + +def test_mark_changed_path_only_tracks_skill_file(tmp_path: Path): + root = tmp_path / "skills" + skill_dir = root / "demo" + skill_dir.mkdir(parents=True) + tracker = SkillHotReloadTracker("SKILL.md") + + tracker.mark_changed_path(str(skill_dir / "notes.txt"), is_directory=False, skill_roots=[str(root)]) + assert tracker.pop_changed_dirs(str(root.resolve())) == [] + + tracker.mark_changed_path(str(skill_dir / "SKILL.md"), is_directory=False, skill_roots=[str(root)]) + changed = tracker.pop_changed_dirs(str(root.resolve())) + assert changed == [skill_dir.resolve()] + + +def test_resolve_root_key_and_normalize_targets(tmp_path: Path): + root = tmp_path / "skills" + nested = root / "a" / "b" + nested.mkdir(parents=True) + + key = SkillHotReloadTracker.resolve_root_key(nested, [str(root)]) + assert key == str(root.resolve()) + + deduped = SkillHotReloadTracker.normalize_scan_targets([root / "a", nested, root / "c"]) + assert deduped == [root / "a", root / "c"] diff --git a/tests/skills/test_registry.py b/tests/skills/test_registry.py new file mode 100644 index 000000000..ca7c9c333 --- /dev/null +++ b/tests/skills/test_registry.py @@ -0,0 +1,91 @@ +# 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 trpc_agent_sdk.skills._registry. + +Covers: +- SkillRegistry: register, unregister, get, get_all, search, clear +- Duplicate registration error +- Singleton behavior +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.skills._registry import SkillRegistry + + +def _dummy_skill_fn(): + pass + + +def _another_skill_fn(): + pass + + +class TestSkillRegistry: + def setup_method(self): + self.registry = SkillRegistry.__new__(SkillRegistry) + self.registry._skills = {} + + def test_register_and_get(self): + self.registry.register("test-skill", _dummy_skill_fn) + result = self.registry.get("test-skill") + assert result is _dummy_skill_fn + + def test_register_duplicate_raises(self): + self.registry.register("dup", _dummy_skill_fn) + with pytest.raises(ValueError, match="already registered"): + self.registry.register("dup", _another_skill_fn) + + def test_get_nonexistent_returns_none(self): + assert self.registry.get("nonexistent") is None + + def test_unregister(self): + self.registry.register("to-remove", _dummy_skill_fn) + self.registry.unregister("to-remove") + assert self.registry.get("to-remove") is None + + def test_unregister_nonexistent_is_noop(self): + self.registry.unregister("nonexistent") + + def test_get_all(self): + self.registry.register("a", _dummy_skill_fn) + self.registry.register("b", _another_skill_fn) + result = self.registry.get_all() + assert len(result) == 2 + assert _dummy_skill_fn in result + assert _another_skill_fn in result + + def test_get_all_empty(self): + assert self.registry.get_all() == [] + + def test_search_by_name(self): + self.registry.register("weather-tool", _dummy_skill_fn) + self.registry.register("data-analysis", _another_skill_fn) + results = self.registry.search("weather") + assert len(results) == 1 + assert results[0] is _dummy_skill_fn + + def test_search_case_insensitive(self): + self.registry.register("WeatherTool", _dummy_skill_fn) + results = self.registry.search("weather") + assert len(results) == 1 + + def test_search_no_match(self): + self.registry.register("foo", _dummy_skill_fn) + assert self.registry.search("bar") == [] + + def test_search_empty_query(self): + self.registry.register("foo", _dummy_skill_fn) + results = self.registry.search("") + assert len(results) == 1 + + def test_clear(self): + self.registry.register("a", _dummy_skill_fn) + self.registry.register("b", _another_skill_fn) + self.registry.clear() + assert self.registry.get_all() == [] diff --git a/tests/skills/test_repository.py b/tests/skills/test_repository.py new file mode 100644 index 000000000..6c2050961 --- /dev/null +++ b/tests/skills/test_repository.py @@ -0,0 +1,474 @@ +# 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 trpc_agent_sdk.skills._repository. + +Covers: +- _split_front_matter: YAML parsing, edge cases +- _parse_tools_from_body: Tools section extraction +- _is_doc_file: file extension check +- FsSkillRepository: indexing, get, summaries, skill_list, path, refresh, _read_docs +- BaseSkillRepository abstract contract +- create_default_skill_repository factory +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from trpc_agent_sdk.skills._repository import ( + BASE_DIR_PLACEHOLDER, + BaseSkillRepository, + CachedFsSkillRepository, + FsSkillRepository, + create_default_skill_repository, +) +from trpc_agent_sdk.skills._utils import is_doc_file + + +# --------------------------------------------------------------------------- +# _split_front_matter +# --------------------------------------------------------------------------- + +class TestSplitFrontMatter: + def test_no_front_matter(self): + fm, body = FsSkillRepository.from_markdown("# Hello\nworld") + assert fm == {} + assert body == "# Hello\nworld" + + def test_with_front_matter(self): + content = "---\nname: test\ndescription: Test skill\n---\n# Body" + fm, body = FsSkillRepository.from_markdown(content) + assert fm["name"] == "test" + assert fm["description"] == "Test skill" + assert body == "# Body" + + def test_crlf_normalization(self): + content = "---\r\nname: test\r\n---\r\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert fm["name"] == "test" + assert body == "body" + + def test_invalid_yaml_returns_empty_dict(self): + content = "---\n: : : invalid\n---\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert body == "body" + + def test_non_dict_yaml_returns_empty_dict(self): + content = "---\n- item1\n- item2\n---\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert fm == {} + assert body == "body" + + def test_unclosed_front_matter(self): + content = "---\nname: test\nno closing" + fm, body = FsSkillRepository.from_markdown(content) + assert fm == {} + assert body == content + + def test_none_values_become_empty_string(self): + content = "---\nname:\n---\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert fm["name"] == "" + + def test_none_key_converted_to_string(self): + content = "---\nname: test\n---\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert fm["name"] == "test" + assert body == "body" + + def test_no_dash_prefix(self): + content = "no front matter at all" + fm, body = FsSkillRepository.from_markdown(content) + assert fm == {} + assert body == content + + +# --------------------------------------------------------------------------- +# _parse_tools_from_body +# --------------------------------------------------------------------------- + +class TestParseToolsFromBody: + def test_basic_tools_section(self): + body = "Tools:\n- tool_a\n- tool_b\n\nOverview" + tools = FsSkillRepository._parse_tools_from_body(body) + assert tools == ["tool_a", "tool_b"] + + def test_no_tools_section(self): + body = "# Just markdown\nNo tools here" + assert FsSkillRepository._parse_tools_from_body(body) == [] + + def test_tools_section_stops_at_next_section(self): + body = "Tools:\n- tool_a\nOverview\nMore content" + tools = FsSkillRepository._parse_tools_from_body(body) + assert tools == ["tool_a"] + + def test_tools_section_skips_headings(self): + body = "Tools:\n# Comment\n- tool_a\n" + tools = FsSkillRepository._parse_tools_from_body(body) + assert tools == ["tool_a"] + + def test_empty_body(self): + assert FsSkillRepository._parse_tools_from_body("") == [] + + def test_tools_with_description_colon(self): + body = "Tools:\n- tool_a\nDescription: something\n" + tools = FsSkillRepository._parse_tools_from_body(body) + assert tools == ["tool_a"] + + +# --------------------------------------------------------------------------- +# _is_doc_file +# --------------------------------------------------------------------------- + +class TestIsDocFile: + def test_markdown(self): + assert is_doc_file("readme.md") is True + assert is_doc_file("README.MD") is True + + def test_text(self): + assert is_doc_file("notes.txt") is True + assert is_doc_file("NOTES.TXT") is True + + def test_non_doc(self): + assert is_doc_file("script.py") is False + assert is_doc_file("data.json") is False + + +# --------------------------------------------------------------------------- +# FsSkillRepository +# --------------------------------------------------------------------------- + +def _create_skill_dir(root: Path, name: str, description: str = "", body: str = "", + docs: dict[str, str] = None) -> Path: + """Helper to create a skill directory with SKILL.md and optional docs.""" + skill_dir = root / name + skill_dir.mkdir(parents=True, exist_ok=True) + + front_matter = f"---\nname: {name}\ndescription: {description}\n---\n" + content = front_matter + (body or f"# {name}\n") + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + + for doc_name, doc_content in (docs or {}).items(): + doc_path = skill_dir / doc_name + doc_path.parent.mkdir(parents=True, exist_ok=True) + doc_path.write_text(doc_content, encoding="utf-8") + + return skill_dir + + +class TestFsSkillRepository: + def test_index_finds_skills(self, tmp_path): + _create_skill_dir(tmp_path, "skill-a", "Skill A description") + _create_skill_dir(tmp_path, "skill-b", "Skill B description") + repo = FsSkillRepository(str(tmp_path)) + assert sorted(repo.skill_list()) == ["skill-a", "skill-b"] + + def test_get_returns_skill(self, tmp_path): + _create_skill_dir(tmp_path, "test-skill", "Test", body="# Test Body\n") + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("test-skill") + assert skill.summary.name == "test-skill" + assert "Test Body" in skill.body + + def test_get_nonexistent_raises(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + with pytest.raises(ValueError, match="not found"): + repo.get("nonexistent") + + def test_path_returns_dir(self, tmp_path): + _create_skill_dir(tmp_path, "my-skill") + repo = FsSkillRepository(str(tmp_path)) + p = repo.path("my-skill") + assert "my-skill" in p + + def test_path_nonexistent_raises(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + with pytest.raises(ValueError, match="not found"): + repo.path("nonexistent") + + def test_summaries(self, tmp_path): + _create_skill_dir(tmp_path, "a-skill", "Alpha") + _create_skill_dir(tmp_path, "b-skill", "Beta") + repo = FsSkillRepository(str(tmp_path)) + summaries = repo.summaries() + assert len(summaries) == 2 + names = [s.name for s in summaries] + assert "a-skill" in names + assert "b-skill" in names + + def test_base_repository_reads_body_for_summaries(self, tmp_path): + _create_skill_dir(tmp_path, "summary-baseline", "Summary", body="# Large Body\n") + repo = FsSkillRepository(str(tmp_path)) + original_read_text = Path.read_text + + with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text: + summaries = repo.summaries() + + assert [summary.name for summary in summaries] == ["summary-baseline"] + assert mock_read_text.call_count == 1 + + def test_cached_repository_summaries_do_not_read_skill_body(self, tmp_path): + _create_skill_dir(tmp_path, "summary-only", "Summary", body="# Large Body\n") + + with patch.object(Path, "read_text", side_effect=AssertionError("body should not be read")): + repo = CachedFsSkillRepository(str(tmp_path)) + summaries = repo.summaries() + + assert [summary.name for summary in summaries] == ["summary-only"] + assert summaries[0].description == "Summary" + + def test_base_repository_does_not_reuse_skill_body(self, tmp_path): + _create_skill_dir(tmp_path, "uncached-skill", "Uncached", body="# Uncached Body\n") + repo = FsSkillRepository(str(tmp_path)) + original_read_text = Path.read_text + + with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text: + repo.get("uncached-skill") + repo.get("uncached-skill") + + assert mock_read_text.call_count == 2 + + def test_cached_repository_get_reuses_cached_skill_body(self, tmp_path): + _create_skill_dir(tmp_path, "cached-skill", "Cached", body="# Cached Body\n") + repo = CachedFsSkillRepository(str(tmp_path)) + original_read_text = Path.read_text + + with patch.object(Path, "read_text", autospec=True, side_effect=original_read_text) as mock_read_text: + first = repo.get("cached-skill") + second = repo.get("cached-skill") + + assert first.body == second.body + assert mock_read_text.call_count == 1 + + def test_cached_repository_get_refreshes_cached_body_when_skill_file_changes(self, tmp_path): + skill_dir = _create_skill_dir(tmp_path, "mutable-skill", "Before", body="# Before\n") + skill_file = skill_dir / "SKILL.md" + repo = CachedFsSkillRepository(str(tmp_path)) + first = repo.get("mutable-skill") + + skill_file.write_text("---\nname: mutable-skill\ndescription: After\n---\n# After changed body\n", + encoding="utf-8") + + second = repo.get("mutable-skill") + + assert "Before" in first.body + assert "After changed body" in second.body + assert second.summary.description == "After" + + def test_refresh(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + assert repo.skill_list() == [] + _create_skill_dir(tmp_path, "new-skill") + repo.refresh() + assert "new-skill" in repo.skill_list() + + def test_reads_doc_files(self, tmp_path): + _create_skill_dir(tmp_path, "with-docs", docs={ + "guide.md": "# Guide content", + "notes.txt": "some notes", + }) + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("with-docs") + assert len(skill.resources) == 2 + paths = [r.path for r in skill.resources] + assert "guide.md" in paths + assert "notes.txt" in paths + + def test_base_dir_placeholder_replaced(self, tmp_path): + body = f"Path is {BASE_DIR_PLACEHOLDER}/scripts\n" + _create_skill_dir(tmp_path, "placeholder-skill", body=body) + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("placeholder-skill") + assert BASE_DIR_PLACEHOLDER not in skill.body + assert str(tmp_path) in skill.body + + def test_base_dir_placeholder_in_docs(self, tmp_path): + _create_skill_dir(tmp_path, "doc-placeholder", docs={ + "ref.md": f"See {BASE_DIR_PLACEHOLDER}/data", + }) + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("doc-placeholder") + for r in skill.resources: + if r.path == "ref.md": + assert BASE_DIR_PLACEHOLDER not in r.content + + def test_skill_without_name_uses_dirname(self, tmp_path): + skill_dir = tmp_path / "dirname-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\ndescription: No name field\n---\nBody\n") + repo = FsSkillRepository(str(tmp_path)) + assert "dirname-skill" in repo.skill_list() + + def test_first_occurrence_wins(self, tmp_path): + root1 = tmp_path / "root1" + root2 = tmp_path / "root2" + _create_skill_dir(root1, "same-name", "First") + _create_skill_dir(root2, "same-name", "Second") + repo = FsSkillRepository(str(root1), str(root2)) + skill = repo.get("same-name") + assert skill.summary.description == "First" + + def test_parses_tools_from_body(self, tmp_path): + body = "Tools:\n- get_weather\n- get_data\n\nOverview\n" + _create_skill_dir(tmp_path, "tools-skill", body=body) + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("tools-skill") + assert skill.tools == ["get_weather", "get_data"] + + def test_skips_git_and_hidden_files(self, tmp_path): + skill_dir = _create_skill_dir(tmp_path, "hidden-test") + (skill_dir / ".hidden.md").write_text("hidden") + git_dir = skill_dir / ".git" + git_dir.mkdir() + (git_dir / "config.md").write_text("git config") + repo = FsSkillRepository(str(tmp_path)) + skill = repo.get("hidden-test") + for r in skill.resources: + assert not r.path.startswith(".") + assert ".git" not in r.path + + def test_from_markdown_classmethod(self): + content = "---\nname: test\n---\nbody" + fm, body = FsSkillRepository.from_markdown(content) + assert fm["name"] == "test" + assert body == "body" + + def test_workspace_runtime_property(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + assert repo.workspace_runtime is not None + + def test_skill_run_env_default(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + assert repo.skill_run_env("any") == {} + + def test_user_prompt_default(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + assert repo.user_prompt() == "" + + def test_duplicate_roots_deduplicated(self, tmp_path): + _create_skill_dir(tmp_path, "skill") + repo = FsSkillRepository(str(tmp_path), str(tmp_path)) + assert repo.skill_list() == ["skill"] + + def test_empty_roots(self): + repo = FsSkillRepository() + assert repo.skill_list() == [] + + +# --------------------------------------------------------------------------- +# create_default_skill_repository +# --------------------------------------------------------------------------- + +class TestCreateDefaultSkillRepository: + def test_creates_repository(self, tmp_path): + _create_skill_dir(tmp_path, "test") + repo = create_default_skill_repository(str(tmp_path)) + assert isinstance(repo, CachedFsSkillRepository) + assert isinstance(repo, FsSkillRepository) + assert "test" in repo.skill_list() + + +# --------------------------------------------------------------------------- +# FsSkillRepository — edge cases +# --------------------------------------------------------------------------- + +class TestFsSkillRepositoryEdgeCases: + def test_list_root_handling(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + # Passing a list as root (gets flattened) + repo2 = FsSkillRepository(str(tmp_path)) + assert isinstance(repo2.skill_list(), list) + + def test_summaries_with_parse_error(self, tmp_path): + skill_dir = tmp_path / "broken" + skill_dir.mkdir() + skill_file = skill_dir / "SKILL.md" + skill_file.write_text("---\nname: broken\n---\n# Body\n") + repo = FsSkillRepository(str(tmp_path)) + # Delete the SKILL.md after indexing to trigger read error + skill_file.unlink() + summaries = repo.summaries() + # Should handle gracefully (warning logged, skill skipped) + assert isinstance(summaries, list) + + def test_resolve_skill_root_error(self, tmp_path): + # Create a repo with an invalid root that will fail resolution + repo = FsSkillRepository(str(tmp_path)) + assert isinstance(repo.skill_list(), list) + + def test_parse_tools_static_method(self): + tools = FsSkillRepository._parse_tools_from_body( + "Tools:\n- get_weather\n- get_forecast\n" + ) + assert tools == ["get_weather", "get_forecast"] + + def test_index_skips_empty_root(self, tmp_path): + repo = FsSkillRepository(str(tmp_path)) + # Should not raise when encountering empty root + repo._skill_roots.append("") + repo.refresh() + + def test_read_docs_error_handling(self, tmp_path): + skill_dir = _create_skill_dir(tmp_path, "doc-err") + # Create a doc that will fail reading (binary file misidentified) + doc_path = skill_dir / "broken.md" + doc_path.write_bytes(b'\x80\x81invalid utf8 continuation') + repo = FsSkillRepository(str(tmp_path)) + # get() should still work, skipping unreadable docs + skill = repo.get("doc-err") + assert skill is not None + + +# --------------------------------------------------------------------------- +# BaseSkillRepository — abstract methods +# --------------------------------------------------------------------------- + +class TestBaseSkillRepositoryAbstract: + def test_user_prompt_default(self): + class _Repo(BaseSkillRepository): + def summaries(self): + return [] + + def get(self, name: str): + raise ValueError(name) + + def skill_list(self, mode: str = "all"): + return [] + + def path(self, name: str) -> str: + return "" + + def refresh(self) -> None: + return None + + repo = _Repo(workspace_runtime=MagicMock()) + assert BaseSkillRepository.user_prompt(repo) == "" + + def test_skill_run_env_default(self): + class _Repo(BaseSkillRepository): + def summaries(self): + return [] + + def get(self, name: str): + raise ValueError(name) + + def skill_list(self, mode: str = "all"): + return [] + + def path(self, name: str) -> str: + return "" + + def refresh(self) -> None: + return None + + repo = _Repo(workspace_runtime=MagicMock()) + result = BaseSkillRepository.skill_run_env(repo, "skill") + assert result == {} diff --git a/tests/skills/test_skill_config.py b/tests/skills/test_skill_config.py new file mode 100644 index 000000000..b8284dd77 --- /dev/null +++ b/tests/skills/test_skill_config.py @@ -0,0 +1,42 @@ +# 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. + +from unittest.mock import MagicMock + +from trpc_agent_sdk.skills._constants import SKILL_CONFIG_KEY +from trpc_agent_sdk.skills._constants import SkillLoadModeNames +from trpc_agent_sdk.skills._skill_config import DEFAULT_SKILL_CONFIG +from trpc_agent_sdk.skills._skill_config import get_skill_config +from trpc_agent_sdk.skills._skill_config import get_skill_load_mode +from trpc_agent_sdk.skills._skill_config import is_exist_skill_config +from trpc_agent_sdk.skills._skill_config import set_skill_config + + +def test_get_skill_config_uses_metadata_default(): + agent_ctx = MagicMock() + agent_ctx.get_metadata = MagicMock(return_value=DEFAULT_SKILL_CONFIG) + assert get_skill_config(agent_ctx) == DEFAULT_SKILL_CONFIG + + +def test_set_skill_config_writes_metadata(): + agent_ctx = MagicMock() + config = {"skill_processor": {"load_mode": "session"}} + set_skill_config(agent_ctx, config) + agent_ctx.with_metadata.assert_called_once_with(SKILL_CONFIG_KEY, config) + + +def test_get_skill_load_mode_fallback_turn_on_invalid(): + agent_ctx = MagicMock() + agent_ctx.get_metadata = MagicMock(return_value={"skill_processor": {"load_mode": "bad"}}) + ctx = MagicMock() + ctx.agent_context = agent_ctx + assert get_skill_load_mode(ctx) == SkillLoadModeNames.TURN.value + + +def test_is_exist_skill_config_checks_key(): + agent_ctx = MagicMock() + agent_ctx.metadata = {SKILL_CONFIG_KEY: {}} + assert is_exist_skill_config(agent_ctx) is True diff --git a/tests/skills/test_skill_profile.py b/tests/skills/test_skill_profile.py new file mode 100644 index 000000000..b091c1591 --- /dev/null +++ b/tests/skills/test_skill_profile.py @@ -0,0 +1,41 @@ +# 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. + +import pytest + +from trpc_agent_sdk.skills._constants import SkillProfileNames +from trpc_agent_sdk.skills._skill_profile import SkillProfileFlags + + +def test_normalize_profile(): + assert SkillProfileFlags.normalize_profile("knowledge_only") == SkillProfileNames.KNOWLEDGE_ONLY.value + assert SkillProfileFlags.normalize_profile("unknown") == SkillProfileNames.FULL.value + + +def test_preset_flags_knowledge_only(): + flags = SkillProfileFlags.preset_flags("knowledge_only") + assert flags.has_knowledge_tools() is True + assert flags.requires_execution_tools() is False + + +def test_resolve_flags_with_forbidden_tool(): + flags = SkillProfileFlags.resolve_flags("full", forbidden_tools=["skill_write_stdin"]) + assert flags.exec is True + assert flags.write_stdin is False + + +def test_validate_dependency_error(): + flags = SkillProfileFlags(run=False, exec=True) + with pytest.raises(ValueError, match="requires"): + flags.validate() + + +def test_without_interactive_execution(): + flags = SkillProfileFlags.resolve_flags("full") + narrowed = flags.without_interactive_execution() + assert narrowed.run is True + assert narrowed.exec is False + assert narrowed.poll_session is False diff --git a/tests/skills/test_state_keys.py b/tests/skills/test_state_keys.py new file mode 100644 index 000000000..e8df27f44 --- /dev/null +++ b/tests/skills/test_state_keys.py @@ -0,0 +1,35 @@ +# 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. + +from trpc_agent_sdk.skills._state_keys import docs_key +from trpc_agent_sdk.skills._state_keys import docs_prefix +from trpc_agent_sdk.skills._state_keys import loaded_key +from trpc_agent_sdk.skills._state_keys import loaded_order_key +from trpc_agent_sdk.skills._state_keys import loaded_prefix +from trpc_agent_sdk.skills._state_keys import to_persistent_prefix +from trpc_agent_sdk.skills._state_keys import tool_key +from trpc_agent_sdk.skills._state_keys import tool_prefix + + +def test_loaded_key_legacy_fallback(): + assert loaded_key("", "demo") == "temp:skill:loaded:demo" + + +def test_scoped_keys_escape_agent_name(): + assert loaded_key("agent/a", "demo") == "temp:skill:loaded_by_agent:agent%2Fa/demo" + assert docs_key("agent/a", "demo") == "temp:skill:docs_by_agent:agent%2Fa/demo" + assert tool_key("agent/a", "demo") == "temp:skill:tools_by_agent:agent%2Fa/demo" + + +def test_prefix_helpers(): + assert loaded_prefix("") == "temp:skill:loaded:" + assert docs_prefix("") == "temp:skill:docs:" + assert tool_prefix("") == "temp:skill:tools:" + assert loaded_order_key("agent/a") == "temp:skill:loaded_order_by_agent:agent%2Fa" + + +def test_to_persistent_prefix(): + assert to_persistent_prefix("temp:skill:loaded:demo") == "skill:loaded:demo" diff --git a/tests/skills/test_state_migration.py b/tests/skills/test_state_migration.py new file mode 100644 index 000000000..02495a3b9 --- /dev/null +++ b/tests/skills/test_state_migration.py @@ -0,0 +1,71 @@ +# 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. +"""Tests for legacy skill state migration.""" + +from unittest.mock import Mock + +from trpc_agent_sdk.skills._constants import SKILL_DOCS_STATE_KEY_PREFIX +from trpc_agent_sdk.skills._constants import SKILL_LOADED_STATE_KEY_PREFIX +from trpc_agent_sdk.skills._state_keys import docs_key +from trpc_agent_sdk.skills._state_keys import loaded_key +from trpc_agent_sdk.skills._state_migration import SKILLS_LEGACY_MIGRATION_STATE_KEY +from trpc_agent_sdk.skills._state_migration import maybe_migrate_legacy_skill_state + + +def _build_ctx(*, state=None, delta=None, agent_name: str = "agent-a") -> Mock: + ctx = Mock() + ctx.session = Mock() + ctx.session.state = dict(state or {}) + ctx.session.events = [] + ctx.actions = Mock() + ctx.actions.state_delta = dict(delta or {}) + ctx.agent = Mock() + ctx.agent.name = agent_name + return ctx + + +class TestMaybeMigrateLegacySkillState: + def test_migrates_loaded_legacy_key_to_temp(self): + legacy_key = f"{SKILL_LOADED_STATE_KEY_PREFIX}demo-skill" + temp_key = loaded_key("agent-a", "demo-skill") + ctx = _build_ctx(state={legacy_key: "1"}) + + maybe_migrate_legacy_skill_state(ctx) + + assert ctx.actions.state_delta[SKILLS_LEGACY_MIGRATION_STATE_KEY] is True + assert ctx.actions.state_delta[temp_key] == "1" + assert ctx.actions.state_delta[legacy_key] is None + + def test_migrates_docs_legacy_key_to_temp(self): + legacy_key = f"{SKILL_DOCS_STATE_KEY_PREFIX}demo-skill" + temp_key = docs_key("agent-a", "demo-skill") + value = '["README.md"]' + ctx = _build_ctx(state={legacy_key: value}) + + maybe_migrate_legacy_skill_state(ctx) + + assert ctx.actions.state_delta[SKILLS_LEGACY_MIGRATION_STATE_KEY] is True + assert ctx.actions.state_delta[temp_key] == value + assert ctx.actions.state_delta[legacy_key] is None + + def test_existing_scoped_key_skips_copy_and_only_clears_legacy(self): + legacy_key = f"{SKILL_LOADED_STATE_KEY_PREFIX}demo-skill" + temp_key = loaded_key("agent-a", "demo-skill") + ctx = _build_ctx(state={legacy_key: "legacy", temp_key: "existing"}) + + maybe_migrate_legacy_skill_state(ctx) + + assert ctx.actions.state_delta[SKILLS_LEGACY_MIGRATION_STATE_KEY] is True + assert ctx.actions.state_delta[legacy_key] is None + assert temp_key not in ctx.actions.state_delta + + def test_migration_is_idempotent_when_marker_exists(self): + legacy_key = f"{SKILL_LOADED_STATE_KEY_PREFIX}demo-skill" + ctx = _build_ctx(state={legacy_key: "1", SKILLS_LEGACY_MIGRATION_STATE_KEY: True}) + + maybe_migrate_legacy_skill_state(ctx) + + assert ctx.actions.state_delta == {} diff --git a/tests/skills/test_state_order.py b/tests/skills/test_state_order.py new file mode 100644 index 000000000..360d58b55 --- /dev/null +++ b/tests/skills/test_state_order.py @@ -0,0 +1,24 @@ +# 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. + +from trpc_agent_sdk.skills._state_order import marshal_loaded_order +from trpc_agent_sdk.skills._state_order import parse_loaded_order +from trpc_agent_sdk.skills._state_order import touch_loaded_order + + +def test_parse_loaded_order_from_json_and_bytes(): + assert parse_loaded_order('["a","b","a",""]') == ["a", "b"] + assert parse_loaded_order(b'["x","y"]') == ["x", "y"] + assert parse_loaded_order(b"\xff") == [] + + +def test_marshal_loaded_order_normalizes(): + assert marshal_loaded_order(["a", "a", " ", "b"]) == '["a", "b"]' + assert marshal_loaded_order([]) == "" + + +def test_touch_loaded_order_moves_items_to_tail(): + assert touch_loaded_order(["a", "b", "c"], "b", "a") == ["c", "b", "a"] diff --git a/tests/skills/test_toolset.py b/tests/skills/test_toolset.py new file mode 100644 index 000000000..3f7f90bf9 --- /dev/null +++ b/tests/skills/test_toolset.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 Apache-2.0. +"""Unit tests for trpc_agent_sdk.skills._toolset. + +Covers: +- SkillToolSet initialization +- SkillToolSet.get_tools: returns expected tool set +- repository property +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from trpc_agent_sdk.skills._toolset import SkillToolSet + + +def _make_ctx(): + ctx = MagicMock() + ctx.agent_context = MagicMock() + ctx.agent_context.with_metadata = MagicMock() + return ctx + + +class TestSkillToolSetInit: + def test_default_init(self, tmp_path): + ts = SkillToolSet(paths=[str(tmp_path)]) + assert ts.name == "skill_toolset" + assert ts.repository is not None + + def test_custom_repository(self): + mock_repo = MagicMock() + mock_repo.workspace_runtime = MagicMock() + ts = SkillToolSet(repository=mock_repo) + assert ts.repository is mock_repo + + +class TestSkillToolSetGetTools: + async def test_get_tools_returns_tools(self, tmp_path): + ts = SkillToolSet(paths=[str(tmp_path)]) + ctx = _make_ctx() + tools = await ts.get_tools(ctx) + assert len(tools) > 0 + + async def test_get_tools_includes_run_and_exec(self, tmp_path): + ts = SkillToolSet(paths=[str(tmp_path)]) + ctx = _make_ctx() + tools = await ts.get_tools(ctx) + tool_names = [t.name for t in tools] + assert "skill_run" in tool_names + assert "skill_exec" in tool_names + + async def test_get_tools_includes_function_tools(self, tmp_path): + ts = SkillToolSet(paths=[str(tmp_path)]) + ctx = _make_ctx() + tools = await ts.get_tools(ctx) + tool_names = [t.name for t in tools] + assert "skill_load" in tool_names + assert "skill_list" in tool_names + assert "skill_list_docs" in tool_names + assert "skill_list_tools" in tool_names + assert "skill_select_docs" in tool_names + assert "skill_select_tools" in tool_names + + async def test_get_tools_sets_metadata(self, tmp_path): + ts = SkillToolSet(paths=[str(tmp_path)]) + ctx = _make_ctx() + await ts.get_tools(ctx) + ctx.agent_context.with_metadata.assert_called() diff --git a/tests/skills/test_types.py b/tests/skills/test_types.py new file mode 100644 index 000000000..dc33a15a2 --- /dev/null +++ b/tests/skills/test_types.py @@ -0,0 +1,392 @@ +# 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 trpc_agent_sdk.skills._types. + +Covers: +- parse_datetime / format_datetime helpers +- SkillRequires, SkillFrontMatter, SkillConfig, SkillSummary, SkillResource defaults +- Skill model construction +- SkillMetadata: from_dict, to_dict, round-trip +- SkillWorkspaceInputRecord: from_dict, to_dict +- SkillWorkspaceOutputRecord: from_dict, to_dict +- SkillWorkspaceMetadata: from_dict, to_dict, nested parsing +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import pytest + +from trpc_agent_sdk.skills._types import ( + Skill, + SkillConfig, + SkillFrontMatter, + SkillMetadata, + SkillRequires, + SkillResource, + SkillSummary, + SkillWorkspaceInputRecord, + SkillWorkspaceMetadata, + SkillWorkspaceOutputRecord, + format_datetime, + parse_datetime, +) + + +# --------------------------------------------------------------------------- +# parse_datetime +# --------------------------------------------------------------------------- + +class TestParseDatetime: + def test_parse_none_returns_now(self): + result = parse_datetime(None) + assert isinstance(result, datetime) + + def test_parse_empty_string_returns_now(self): + result = parse_datetime("") + assert isinstance(result, datetime) + + def test_parse_iso_string(self): + dt = parse_datetime("2025-06-15T10:30:00") + assert dt.year == 2025 + assert dt.month == 6 + assert dt.day == 15 + assert dt.hour == 10 + + def test_parse_datetime_object(self): + original = datetime(2025, 1, 1, 12, 0, 0) + result = parse_datetime(original) + assert result is original + + def test_parse_int_returns_now(self): + result = parse_datetime(12345) + assert isinstance(result, datetime) + + def test_parse_false_returns_now(self): + result = parse_datetime(False) + assert isinstance(result, datetime) + + +# --------------------------------------------------------------------------- +# format_datetime +# --------------------------------------------------------------------------- + +class TestFormatDatetime: + def test_format_none_returns_iso_string(self): + result = format_datetime(None) + assert isinstance(result, str) + datetime.fromisoformat(result) + + def test_format_datetime(self): + dt = datetime(2025, 6, 15, 10, 30, 0) + result = format_datetime(dt) + assert "2025-06-15" in result + assert "10:30:00" in result + + +# --------------------------------------------------------------------------- +# Pydantic model defaults +# --------------------------------------------------------------------------- + +class TestSkillRequires: + def test_defaults(self): + r = SkillRequires() + assert r.bins == [] + assert r.any_bins == [] + assert r.env == [] + assert r.config == [] + assert r.install == [] + + +class TestSkillFrontMatter: + def test_defaults(self): + fm = SkillFrontMatter() + assert fm.skill_key == "" + assert fm.primary_env == "" + assert fm.emoji == "" + assert fm.homepage == "" + assert fm.always is False + assert fm.os == [] + assert isinstance(fm.requires, SkillRequires) + + def test_with_values(self): + fm = SkillFrontMatter( + skill_key="test-key", + primary_env="API_KEY", + emoji="🧪", + always=True, + os=["linux", "darwin"], + ) + assert fm.skill_key == "test-key" + assert fm.always is True + assert len(fm.os) == 2 + + +class TestSkillConfig: + def test_defaults(self): + c = SkillConfig() + assert c.enabled is None + assert c.api_key == "" + assert c.env == {} + + +class TestSkillSummary: + def test_defaults(self): + s = SkillSummary() + assert s.name == "" + assert s.description == "" + + def test_with_values(self): + s = SkillSummary(name="test", description="desc") + assert s.name == "test" + + +class TestSkillResource: + def test_creation(self): + r = SkillResource(path="docs/readme.md", content="# Hello") + assert r.path == "docs/readme.md" + assert r.content == "# Hello" + + +class TestSkill: + def test_defaults(self): + s = Skill() + assert s.body == "" + assert s.resources == [] + assert s.tools == [] + assert s.base_dir == "" + assert isinstance(s.summary, SkillSummary) + + def test_with_values(self): + s = Skill( + summary=SkillSummary(name="test", description="Test skill"), + body="# Test Body", + tools=["tool1", "tool2"], + base_dir="/path/to/skill", + resources=[SkillResource(path="doc.md", content="doc")], + ) + assert s.summary.name == "test" + assert len(s.tools) == 2 + assert len(s.resources) == 1 + + +# --------------------------------------------------------------------------- +# SkillMetadata +# --------------------------------------------------------------------------- + +class TestSkillMetadata: + def test_defaults(self): + m = SkillMetadata() + assert m.name == "" + assert m.rel_path == "" + assert m.digest == "" + assert m.mounted is False + assert m.staged_at is None + + def test_from_dict_full(self): + data = { + "name": "test-skill", + "rel_path": "skills/test", + "digest": "abc123", + "mounted": True, + "staged_at": "2025-06-15T10:30:00", + } + m = SkillMetadata.from_dict(data) + assert m.name == "test-skill" + assert m.rel_path == "skills/test" + assert m.digest == "abc123" + assert m.mounted is True + assert m.staged_at.year == 2025 + + def test_from_dict_empty(self): + m = SkillMetadata.from_dict({}) + assert m.name == "" + assert m.mounted is False + + def test_to_dict(self): + m = SkillMetadata( + name="test", + rel_path="skills/test", + digest="abc", + mounted=True, + staged_at=datetime(2025, 6, 15), + ) + d = m.to_dict() + assert d["name"] == "test" + assert d["rel_path"] == "skills/test" + assert d["digest"] == "abc" + assert d["mounted"] is True + assert "2025-06-15" in d["staged_at"] + + def test_round_trip(self): + original = SkillMetadata( + name="round-trip", + rel_path="skills/rt", + digest="hash", + mounted=True, + staged_at=datetime(2025, 1, 1, 12, 0, 0), + ) + d = original.to_dict() + restored = SkillMetadata.from_dict(d) + assert restored.name == original.name + assert restored.digest == original.digest + assert restored.mounted == original.mounted + + +# --------------------------------------------------------------------------- +# SkillWorkspaceInputRecord +# --------------------------------------------------------------------------- + +class TestSkillWorkspaceInputRecord: + def test_from_dict_full(self): + data = { + "src": "/src/path", + "dst": "/dst/path", + "timestamp": "2025-06-15T10:00:00", + "resolved": "/resolved", + "version": 3, + "mode": "copy", + } + r = SkillWorkspaceInputRecord.from_dict(data) + assert r.src == "/src/path" + assert r.dst == "/dst/path" + assert r.version == 3 + assert r.mode == "copy" + + def test_from_dict_empty(self): + r = SkillWorkspaceInputRecord.from_dict({}) + assert r.src == "" + assert r.version == 0 + + def test_to_dict(self): + r = SkillWorkspaceInputRecord( + src="s", dst="d", resolved="r", version=1, mode="link", + timestamp=datetime(2025, 1, 1), + ) + d = r.to_dict() + assert d["src"] == "s" + assert d["dst"] == "d" + assert d["version"] == 1 + + def test_round_trip(self): + original = SkillWorkspaceInputRecord(src="a", dst="b", version=5) + d = original.to_dict() + restored = SkillWorkspaceInputRecord.from_dict(d) + assert restored.src == original.src + assert restored.version == original.version + + +# --------------------------------------------------------------------------- +# SkillWorkspaceOutputRecord +# --------------------------------------------------------------------------- + +class TestSkillWorkspaceOutputRecord: + def test_from_dict_full(self): + data = { + "globs": ["*.txt", "*.md"], + "limits_hit": 2, + "timestamp": "2025-06-15T10:00:00", + "saved_as": ["out/a.txt"], + "versions": [1, 2], + } + r = SkillWorkspaceOutputRecord.from_dict(data) + assert r.globs == ["*.txt", "*.md"] + assert r.limits_hit == 2 + assert r.saved_as == ["out/a.txt"] + assert r.versions == [1, 2] + + def test_from_dict_empty(self): + r = SkillWorkspaceOutputRecord.from_dict({}) + assert r.globs == [] + assert r.limits_hit == 0 + + def test_to_dict(self): + r = SkillWorkspaceOutputRecord( + globs=["*.py"], + limits_hit=1, + saved_as=["out/test.py"], + versions=[3], + timestamp=datetime(2025, 1, 1), + ) + d = r.to_dict() + assert d["globs"] == ["*.py"] + assert d["limits_hit"] == 1 + assert d["saved_as"] == ["out/test.py"] + assert d["versions"] == [3] + + +# --------------------------------------------------------------------------- +# SkillWorkspaceMetadata +# --------------------------------------------------------------------------- + +class TestSkillWorkspaceMetadata: + def test_defaults(self): + m = SkillWorkspaceMetadata() + assert m.version == 0 + assert m.skills == {} + assert m.inputs == [] + assert m.outputs == [] + + def test_from_dict_full(self): + data = { + "version": 2, + "created_at": "2025-01-01T00:00:00", + "updated_at": "2025-06-15T00:00:00", + "last_access": "2025-06-15T12:00:00", + "skills": { + "weather": { + "name": "weather", + "rel_path": "skills/weather", + "digest": "abc", + "mounted": True, + "staged_at": "2025-06-15T00:00:00", + }, + }, + "inputs": [ + {"src": "s", "dst": "d", "version": 1}, + ], + "outputs": [ + {"globs": ["*.txt"], "limits_hit": 0}, + ], + } + m = SkillWorkspaceMetadata.from_dict(data) + assert m.version == 2 + assert "weather" in m.skills + assert m.skills["weather"].name == "weather" + assert len(m.inputs) == 1 + assert len(m.outputs) == 1 + + def test_from_dict_empty(self): + m = SkillWorkspaceMetadata.from_dict({}) + assert m.version == 1 + assert m.skills == {} + + def test_to_dict(self): + m = SkillWorkspaceMetadata( + version=2, + created_at=datetime(2025, 1, 1), + updated_at=datetime(2025, 6, 15), + last_access=datetime(2025, 6, 15), + ) + m.skills["test"] = SkillMetadata(name="test", digest="hash") + d = m.to_dict() + assert d["version"] == 2 + assert "test" in d["skills"] + + def test_round_trip(self): + m = SkillWorkspaceMetadata(version=3) + m.skills["s1"] = SkillMetadata(name="s1", digest="d1", mounted=True) + m.inputs.append(SkillWorkspaceInputRecord(src="a", dst="b")) + m.outputs.append(SkillWorkspaceOutputRecord(globs=["*.txt"])) + + d = m.to_dict() + restored = SkillWorkspaceMetadata.from_dict(d) + assert restored.version == 3 + assert "s1" in restored.skills + assert len(restored.inputs) == 1 + assert len(restored.outputs) == 1 diff --git a/tests/skills/test_url_root.py b/tests/skills/test_url_root.py new file mode 100644 index 000000000..c9760fff4 --- /dev/null +++ b/tests/skills/test_url_root.py @@ -0,0 +1,636 @@ +# 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 trpc_agent_sdk.skills._url_root. + +Covers: +- ArchiveExtractor: path cleaning, permission sanitization, size limit checks, + archive kind detection, zip/tar extraction +- SkillRootResolver: local paths, file:// URLs, cache directory resolution +- Enum values for CacheConfig, ArchiveExt, ArchiveKind, FilePerm, SizeLimit, TarPerm +""" + +from __future__ import annotations + +import io +import os +import tarfile +import tempfile +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from trpc_agent_sdk.skills._url_root import ( + ArchiveExt, + ArchiveExtractor, + ArchiveKind, + CacheConfig, + FilePerm, + SizeLimit, + SkillRootResolver, + TarPerm, +) + + +# --------------------------------------------------------------------------- +# Enum smoke tests +# --------------------------------------------------------------------------- + +class TestEnums: + def test_cache_config_values(self): + assert CacheConfig.APP_DIR == "trpc-agent-py" + assert CacheConfig.SKILLS_DIR == "skills" + assert CacheConfig.READY_FILE == ".ready" + + def test_archive_ext_values(self): + assert ArchiveExt.ZIP == ".zip" + assert ArchiveExt.TAR_GZ == ".tar.gz" + + def test_archive_kind_ordering(self): + assert ArchiveKind.UNKNOWN < ArchiveKind.ZIP < ArchiveKind.TAR < ArchiveKind.TAR_GZ + + def test_file_perm_values(self): + assert FilePerm.DIR == 0o755 + assert FilePerm.FILE == 0o644 + + def test_size_limit_values(self): + assert SizeLimit.MAX_DOWNLOAD == 64 << 20 + assert SizeLimit.MAX_EXTRACT_FILE == 64 << 20 + assert SizeLimit.MAX_EXTRACT_TOTAL == 256 << 20 + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — path cleaning +# --------------------------------------------------------------------------- + +class TestCleanArchivePath: + def test_normal_path(self): + assert ArchiveExtractor._clean_archive_path("dir/file.txt") == "dir/file.txt" + + def test_root_entry(self): + assert ArchiveExtractor._clean_archive_path(".") == "" + + def test_backslash_normalization(self): + result = ArchiveExtractor._clean_archive_path("dir\\file.txt") + assert "/" in result or "file.txt" in result + + def test_absolute_path_rejected(self): + with pytest.raises(ValueError, match="invalid archive path"): + ArchiveExtractor._clean_archive_path("/etc/passwd") + + def test_traversal_rejected(self): + with pytest.raises(ValueError, match="invalid archive path"): + ArchiveExtractor._clean_archive_path("../escape") + + def test_double_dot_only_rejected(self): + with pytest.raises(ValueError, match="invalid archive path"): + ArchiveExtractor._clean_archive_path("..") + + def test_colon_rejected(self): + with pytest.raises(ValueError, match="invalid archive path"): + ArchiveExtractor._clean_archive_path("C:file.txt") + + def test_leading_dot_slash_stripped(self): + result = ArchiveExtractor._clean_archive_path("./dir/file.txt") + assert not result.startswith("./") + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — permission helpers +# --------------------------------------------------------------------------- + +class TestSanitizePerm: + def test_zero_returns_file_perm(self): + assert ArchiveExtractor._sanitize_perm(0) == FilePerm.FILE + + def test_normal_perm(self): + assert ArchiveExtractor._sanitize_perm(0o755) == 0o755 + + def test_extra_bits_masked(self): + result = ArchiveExtractor._sanitize_perm(0o100755) + assert result == 0o755 + + +class TestTarHeaderPerm: + def test_negative_mode(self): + assert ArchiveExtractor._tar_header_perm(-1) == FilePerm.FILE + + def test_normal_mode(self): + result = ArchiveExtractor._tar_header_perm(0o100644) + assert result == 0o644 + + def test_zero_mode(self): + assert ArchiveExtractor._tar_header_perm(0) == FilePerm.FILE + + def test_exec_mode(self): + result = ArchiveExtractor._tar_header_perm(0o100755) + assert result == 0o755 + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — size limit helpers +# --------------------------------------------------------------------------- + +class TestAddExtractedBytes: + def test_normal(self): + assert ArchiveExtractor._add_extracted_bytes(0, 100) == 100 + + def test_negative_raises(self): + with pytest.raises(RuntimeError, match="negative"): + ArchiveExtractor._add_extracted_bytes(0, -1) + + def test_overflow_raises(self): + with pytest.raises(RuntimeError, match="too large"): + ArchiveExtractor._add_extracted_bytes(SizeLimit.MAX_EXTRACT_TOTAL, 1) + + +class TestValidateTarSize: + def test_normal(self): + ArchiveExtractor._validate_tar_size(100) + + def test_negative_raises(self): + with pytest.raises(RuntimeError, match="negative"): + ArchiveExtractor._validate_tar_size(-1) + + def test_too_large_raises(self): + with pytest.raises(RuntimeError, match="too large"): + ArchiveExtractor._validate_tar_size(SizeLimit.MAX_EXTRACT_FILE + 1) + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — kind detection +# --------------------------------------------------------------------------- + +class TestKindFromName: + def test_zip(self): + assert ArchiveExtractor._kind_from_name("archive.zip") == ArchiveKind.ZIP + + def test_tar(self): + assert ArchiveExtractor._kind_from_name("archive.tar") == ArchiveKind.TAR + + def test_tar_gz(self): + assert ArchiveExtractor._kind_from_name("archive.tar.gz") == ArchiveKind.TAR_GZ + + def test_tgz(self): + assert ArchiveExtractor._kind_from_name("archive.tgz") == ArchiveKind.TAR_GZ + + def test_unknown(self): + assert ArchiveExtractor._kind_from_name("file.py") == ArchiveKind.UNKNOWN + + +class TestDetectKind: + def test_detect_zip(self, tmp_path): + archive = tmp_path / "test.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("test.txt", "hello") + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), "") + assert ext.detect_kind() == ArchiveKind.ZIP + + def test_detect_missing_file(self, tmp_path): + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(tmp_path / "nonexistent"), "") + assert ext.detect_kind() == ArchiveKind.UNKNOWN + + def test_detect_unknown(self, tmp_path): + f = tmp_path / "unknown" + f.write_bytes(b"random data bytes here") + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(f), "") + assert ext.detect_kind() == ArchiveKind.UNKNOWN + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — configuration +# --------------------------------------------------------------------------- + +class TestExtractorConfig: + def test_set_cache_dir(self): + ext = ArchiveExtractor() + ext.set_cache_dir("/tmp/cache") + assert ext.cache_dir == "/tmp/cache" + + def test_set_cache_dir_idempotent(self): + ext = ArchiveExtractor() + ext.set_cache_dir("/first") + ext.set_cache_dir("/second") + assert ext.cache_dir == "/first" + + def test_set_src_and_dest_dir(self): + ext = ArchiveExtractor() + ext.set_src_and_dest_dir("/src", "/dest") + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — zip extraction +# --------------------------------------------------------------------------- + +class TestZipExtraction: + def test_extract_zip(self, tmp_path): + archive = tmp_path / "test.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("dir/file.txt", "hello world") + zf.writestr("root.txt", "root content") + + dest = tmp_path / "output" + dest.mkdir() + + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + + assert (dest / "dir" / "file.txt").read_text() == "hello world" + assert (dest / "root.txt").read_text() == "root content" + + def test_extract_zip_entry_dir(self, tmp_path): + archive = tmp_path / "test.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.mkdir("mydir") + zf.writestr("mydir/file.txt", "content") + + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + assert (dest / "mydir" / "file.txt").exists() + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — tar extraction +# --------------------------------------------------------------------------- + +class TestTarExtraction: + def test_extract_tar(self, tmp_path): + archive = tmp_path / "test.tar" + with tarfile.open(archive, "w") as tf: + data = b"hello tar" + info = tarfile.TarInfo(name="file.txt") + info.size = len(data) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(data)) + + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + assert (dest / "file.txt").read_bytes() == b"hello tar" + + def test_extract_tar_gz(self, tmp_path): + archive = tmp_path / "test.tar.gz" + with tarfile.open(archive, "w:gz") as tf: + data = b"hello tar.gz" + info = tarfile.TarInfo(name="file.txt") + info.size = len(data) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(data)) + + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + assert (dest / "file.txt").read_bytes() == b"hello tar.gz" + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — single skill file +# --------------------------------------------------------------------------- + +class TestWriteSingleSkillFile: + def test_write_skill_md(self, tmp_path): + src = tmp_path / "SKILL.md" + src.write_text("# My skill") + dest = tmp_path / "output" + dest.mkdir() + + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(src), str(dest)) + url = urlparse(f"file://{src}") + ext.extract(url) + assert (dest / "SKILL.md").read_text() == "# My skill" + + +class TestExtractUnsupported: + def test_unsupported_extension_raises(self, tmp_path): + f = tmp_path / "data.csv" + f.write_text("data") + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(f), str(tmp_path / "out")) + with pytest.raises(ValueError, match="unsupported"): + ext.extract(urlparse(f"file://{f}")) + + +# --------------------------------------------------------------------------- +# SkillRootResolver — local paths +# --------------------------------------------------------------------------- + +class TestSkillRootResolverLocal: + def test_empty_string_returns_empty(self): + resolver = SkillRootResolver() + assert resolver.resolve("") == "" + + def test_whitespace_returns_empty(self): + resolver = SkillRootResolver() + assert resolver.resolve(" ") == "" + + def test_local_path_returned_as_is(self): + resolver = SkillRootResolver() + assert resolver.resolve("/usr/local/skills") == "/usr/local/skills" + + def test_relative_path_returned_as_is(self): + resolver = SkillRootResolver() + assert resolver.resolve("skills/local") == "skills/local" + + +# --------------------------------------------------------------------------- +# SkillRootResolver — file:// URLs +# --------------------------------------------------------------------------- + +class TestSkillRootResolverFile: + def test_file_url_directory(self, tmp_path): + resolver = SkillRootResolver() + result = resolver.resolve(f"file://{tmp_path}") + assert str(tmp_path) in result + + def test_file_url_archive(self, tmp_path, monkeypatch): + archive = tmp_path / "skills.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("SKILL.md", "---\nname: test\n---\n# Test") + monkeypatch.setenv("SKILLS_CACHE_DIR", str(tmp_path / "cache")) + resolver = SkillRootResolver() + result = resolver.resolve(f"file://{archive}") + assert os.path.isdir(result) + + def test_file_url_non_localhost_raises(self): + resolver = SkillRootResolver() + with pytest.raises(ValueError, match="unsupported file URL host"): + resolver.resolve("file://remote-host/path") + + +# --------------------------------------------------------------------------- +# SkillRootResolver — unsupported schemes +# --------------------------------------------------------------------------- + +class TestSkillRootResolverScheme: + def test_unsupported_scheme_raises(self): + resolver = SkillRootResolver() + with pytest.raises(ValueError, match="unsupported"): + resolver.resolve("ftp://example.com/skills") + + +# --------------------------------------------------------------------------- +# SkillRootResolver — static helpers +# --------------------------------------------------------------------------- + +class TestSkillRootResolverHelpers: + def test_file_exists(self, tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello") + assert SkillRootResolver._file_exists(str(f)) is True + + def test_file_exists_missing(self, tmp_path): + assert SkillRootResolver._file_exists(str(tmp_path / "nope")) is False + + def test_file_exists_directory(self, tmp_path): + assert SkillRootResolver._file_exists(str(tmp_path)) is False + + def test_sha256_hex(self): + h = SkillRootResolver._sha256_hex("test") + assert isinstance(h, str) + assert len(h) == 64 + + def test_sha256_hex_deterministic(self): + h1 = SkillRootResolver._sha256_hex("hello") + h2 = SkillRootResolver._sha256_hex("hello") + assert h1 == h2 + + def test_user_cache_dir(self): + result = SkillRootResolver._user_cache_dir() + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# SkillRootResolver — cache directory +# --------------------------------------------------------------------------- + +class TestSkillsCacheDir: + def test_env_override(self, tmp_path, monkeypatch): + monkeypatch.setenv("SKILLS_CACHE_DIR", str(tmp_path)) + resolver = SkillRootResolver() + result = resolver._skills_cache_dir() + assert result == str(tmp_path) + + def test_default_cache_dir(self, monkeypatch): + monkeypatch.delenv("SKILLS_CACHE_DIR", raising=False) + resolver = SkillRootResolver() + result = resolver._skills_cache_dir() + assert isinstance(result, str) + assert len(result) > 0 + + def test_empty_env_uses_default(self, monkeypatch): + monkeypatch.setenv("SKILLS_CACHE_DIR", " ") + resolver = SkillRootResolver() + result = resolver._skills_cache_dir() + assert len(result) > 0 + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — extract_zip_entry edge cases +# --------------------------------------------------------------------------- + +class TestExtractZipEntryEdgeCases: + def test_zip_entry_too_large(self, tmp_path): + archive = tmp_path / "big.zip" + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("file.txt", "x" * 100) + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + assert (dest / "file.txt").exists() + + def test_nil_zip_entry_raises(self): + with pytest.raises(ValueError, match="nil"): + ArchiveExtractor._extract_zip_entry(MagicMock(), None, "/tmp", 0) + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — tar extraction edge cases +# --------------------------------------------------------------------------- + +class TestTarExtractionEdgeCases: + def test_tar_with_directory(self, tmp_path): + archive = tmp_path / "test.tar" + with tarfile.open(archive, "w") as tf: + info = tarfile.TarInfo(name="mydir") + info.type = tarfile.DIRTYPE + info.mode = 0o755 + tf.addfile(info) + + data = b"in subdir" + info2 = tarfile.TarInfo(name="mydir/file.txt") + info2.size = len(data) + info2.mode = 0o644 + tf.addfile(info2, io.BytesIO(data)) + + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + ext.extract(urlparse(f"file://{archive}")) + assert (dest / "mydir" / "file.txt").read_bytes() == b"in subdir" + + def test_tar_symlink_rejected(self, tmp_path): + archive = tmp_path / "sym.tar" + with tarfile.open(archive, "w") as tf: + info = tarfile.TarInfo(name="link.txt") + info.type = tarfile.SYMTYPE + info.linkname = "/etc/passwd" + tf.addfile(info) + + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), str(dest)) + with pytest.raises(ValueError, match="unsupported tar entry"): + ext.extract(urlparse(f"file://{archive}")) + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — detect_kind with gzip +# --------------------------------------------------------------------------- + +class TestDetectKindGzip: + def test_detect_gzip(self, tmp_path): + import gzip + archive = tmp_path / "test.gz" + with gzip.open(archive, "wb") as f: + f.write(b"data") + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(archive), "") + assert ext.detect_kind() == ArchiveKind.TAR_GZ + + def test_detect_short_file(self, tmp_path): + f = tmp_path / "tiny" + f.write_bytes(b"x") + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(f), "") + assert ext.detect_kind() == ArchiveKind.UNKNOWN + + +# --------------------------------------------------------------------------- +# SkillRootResolver — _cache_extracted_root +# --------------------------------------------------------------------------- + +class TestCacheExtractedRoot: + def test_cache_with_ready_file(self, tmp_path, monkeypatch): + monkeypatch.setenv("SKILLS_CACHE_DIR", str(tmp_path / "cache")) + resolver = SkillRootResolver() + resolver._extractor.set_cache_dir(str(tmp_path / "cache")) + + url_result = urlparse("file:///tmp/test.zip") + key = resolver._sha256_hex(url_result.geturl()) + dest = tmp_path / "cache" / key + dest.mkdir(parents=True) + (dest / ".ready").write_text("ok") + + result = resolver._cache_extracted_root(url_result, "/tmp/test.zip") + assert result == str(dest) + + +# --------------------------------------------------------------------------- +# SkillRootResolver — _user_cache_dir platform branches +# --------------------------------------------------------------------------- + +class TestUserCacheDir: + def test_darwin(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setenv("HOME", "/Users/test") + result = SkillRootResolver._user_cache_dir() + assert "Library/Caches" in result + + def test_darwin_no_home(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.delenv("HOME", raising=False) + result = SkillRootResolver._user_cache_dir() + assert result == "" + + def test_linux_xdg(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setenv("XDG_CACHE_HOME", "/tmp/xdg") + result = SkillRootResolver._user_cache_dir() + assert result == "/tmp/xdg" + + def test_linux_xdg_relative_ignored(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.setenv("XDG_CACHE_HOME", "relative/path") + monkeypatch.delenv("HOME", raising=False) + result = SkillRootResolver._user_cache_dir() + assert result == "" + + def test_linux_home_fallback(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.setenv("HOME", "/home/test") + result = SkillRootResolver._user_cache_dir() + assert ".cache" in result + + def test_windows(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setenv("LocalAppData", "C:\\Users\\test\\AppData\\Local") + result = SkillRootResolver._user_cache_dir() + assert "AppData" in result + + def test_linux_no_home(self, monkeypatch): + monkeypatch.setattr("platform.system", lambda: "Linux") + monkeypatch.delenv("XDG_CACHE_HOME", raising=False) + monkeypatch.delenv("HOME", raising=False) + result = SkillRootResolver._user_cache_dir() + assert result == "" + + +# --------------------------------------------------------------------------- +# SkillRootResolver — _skills_cache_dir fallback +# --------------------------------------------------------------------------- + +class TestSkillsCacheDirFallback: + def test_no_user_cache_falls_to_tmp(self, monkeypatch): + monkeypatch.delenv("SKILLS_CACHE_DIR", raising=False) + monkeypatch.setattr(SkillRootResolver, "_user_cache_dir", staticmethod(lambda: "")) + resolver = SkillRootResolver() + result = resolver._skills_cache_dir() + assert "trpc-agent-py" in result + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — _write_single_skill_file +# --------------------------------------------------------------------------- + +class TestWriteSingleSkillFileSizeLimit: + def test_oversized_file_raises(self, tmp_path): + src = tmp_path / "SKILL.md" + src.write_bytes(b"x" * (SizeLimit.MAX_EXTRACT_FILE + 1)) + dest = tmp_path / "output" + dest.mkdir() + ext = ArchiveExtractor() + ext.set_src_and_dest_dir(str(src), str(dest)) + with pytest.raises(RuntimeError, match="too large"): + ext._write_single_skill_file() + + +# --------------------------------------------------------------------------- +# ArchiveExtractor — extract_zip_entry path traversal +# --------------------------------------------------------------------------- + +class TestZipEntryPathTraversal: + def test_zip_clean_path_on_root(self): + result = ArchiveExtractor._clean_archive_path("./subdir/file.txt") + assert result == "subdir/file.txt" diff --git a/tests/skills/test_utils.py b/tests/skills/test_utils.py new file mode 100644 index 000000000..52d189ac5 --- /dev/null +++ b/tests/skills/test_utils.py @@ -0,0 +1,201 @@ +# 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 trpc_agent_sdk.skills._utils. + +Covers: +- compute_dir_digest: hashing stability and content sensitivity +- save_metadata / load_metadata: round-trip, missing file +- ensure_layout: directory creation, metadata initialization +- shell_quote: quoting edge cases +- set_state_delta / get_state_delta +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._types import SkillWorkspaceMetadata +from trpc_agent_sdk.skills._utils import ( + compute_dir_digest, + ensure_layout, + get_state_delta, + load_metadata, + save_metadata, + set_state_delta, + shell_quote, +) + + +def _make_ctx(state_delta=None, session_state=None): + ctx = MagicMock() + ctx.actions.state_delta = state_delta or {} + ctx.session_state = session_state or {} + return ctx + + +# --------------------------------------------------------------------------- +# compute_dir_digest +# --------------------------------------------------------------------------- + +class TestComputeDirDigest: + def test_empty_dir(self, tmp_path): + digest = compute_dir_digest(tmp_path) + assert isinstance(digest, str) + assert len(digest) == 64 + + def test_single_file(self, tmp_path): + (tmp_path / "file.txt").write_text("hello") + d1 = compute_dir_digest(tmp_path) + assert len(d1) == 64 + + def test_content_change_changes_digest(self, tmp_path): + f = tmp_path / "file.txt" + f.write_text("hello") + d1 = compute_dir_digest(tmp_path) + f.write_text("world") + d2 = compute_dir_digest(tmp_path) + assert d1 != d2 + + def test_stable_across_calls(self, tmp_path): + (tmp_path / "a.txt").write_text("a") + (tmp_path / "b.txt").write_text("b") + d1 = compute_dir_digest(tmp_path) + d2 = compute_dir_digest(tmp_path) + assert d1 == d2 + + def test_string_path(self, tmp_path): + (tmp_path / "file.txt").write_text("test") + d = compute_dir_digest(str(tmp_path)) + assert len(d) == 64 + + def test_nested_files(self, tmp_path): + sub = tmp_path / "sub" + sub.mkdir() + (sub / "nested.txt").write_text("nested content") + d = compute_dir_digest(tmp_path) + assert len(d) == 64 + + +# --------------------------------------------------------------------------- +# save_metadata / load_metadata +# --------------------------------------------------------------------------- + +class TestSaveLoadMetadata: + def test_save_and_load(self, tmp_path): + md = SkillWorkspaceMetadata(version=5) + save_metadata(tmp_path, md) + loaded = load_metadata(tmp_path) + assert loaded.version == 5 + + def test_save_updates_updated_at(self, tmp_path): + md = SkillWorkspaceMetadata(version=1) + save_metadata(tmp_path, md) + assert md.updated_at is not None + + def test_load_missing_returns_default(self, tmp_path): + loaded = load_metadata(tmp_path) + assert isinstance(loaded, SkillWorkspaceMetadata) + assert loaded.version == 0 + + def test_load_invalid_json_raises(self, tmp_path): + from trpc_agent_sdk.code_executors import META_FILE_NAME + meta_file = tmp_path / META_FILE_NAME + meta_file.write_text("not json") + with pytest.raises(ValueError, match="Invalid JSON"): + load_metadata(tmp_path) + + def test_string_path(self, tmp_path): + md = SkillWorkspaceMetadata(version=3) + save_metadata(str(tmp_path), md) + loaded = load_metadata(str(tmp_path)) + assert loaded.version == 3 + + +# --------------------------------------------------------------------------- +# ensure_layout +# --------------------------------------------------------------------------- + +class TestEnsureLayout: + def test_creates_subdirectories(self, tmp_path): + paths = ensure_layout(tmp_path) + assert len(paths) == 4 + for p in paths.values(): + assert p.exists() + assert (tmp_path / "work" / "inputs").is_dir() + + def test_creates_metadata_file(self, tmp_path): + from trpc_agent_sdk.code_executors import META_FILE_NAME + ensure_layout(tmp_path) + assert (tmp_path / META_FILE_NAME).exists() + + def test_idempotent(self, tmp_path): + paths1 = ensure_layout(tmp_path) + paths2 = ensure_layout(tmp_path) + assert set(paths1.keys()) == set(paths2.keys()) + + def test_string_path(self, tmp_path): + paths = ensure_layout(str(tmp_path)) + assert len(paths) == 4 + + +# --------------------------------------------------------------------------- +# shell_quote +# --------------------------------------------------------------------------- + +class TestShellQuote: + def test_simple_string(self): + assert shell_quote("hello") == "'hello'" + + def test_empty_string(self): + assert shell_quote("") == "''" + + def test_string_with_single_quote(self): + result = shell_quote("it's") + assert "'" in result + assert "\\" in result + + def test_string_with_spaces(self): + result = shell_quote("hello world") + assert result == "'hello world'" + + def test_string_with_special_chars(self): + result = shell_quote("a$b") + assert result == "'a$b'" + + +# --------------------------------------------------------------------------- +# set_state_delta / get_state_delta +# --------------------------------------------------------------------------- + +class TestStateDelta: + def test_set_state_delta(self): + ctx = _make_ctx() + set_state_delta(ctx, "key", "value") + assert ctx.actions.state_delta["key"] == "value" + + def test_get_state_delta_from_delta(self): + ctx = _make_ctx(state_delta={"key": "delta_val"}) + result = get_state_delta(ctx, "key") + assert result == "delta_val" + + def test_get_state_delta_from_session(self): + ctx = _make_ctx(session_state={"key": "session_val"}) + result = get_state_delta(ctx, "key") + assert result == "session_val" + + def test_get_state_delta_prefers_delta(self): + ctx = _make_ctx(state_delta={"k": "d"}, session_state={"k": "s"}) + assert get_state_delta(ctx, "k") == "d" + + def test_get_state_delta_missing(self): + ctx = _make_ctx() + assert get_state_delta(ctx, "missing") is None diff --git a/tests/skills/tools/__init__.py b/tests/skills/tools/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/skills/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/skills/tools/test_common.py b/tests/skills/tools/test_common.py new file mode 100644 index 000000000..52d712f7c --- /dev/null +++ b/tests/skills/tools/test_common.py @@ -0,0 +1,31 @@ +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills.tools._common import get_staged_workspace_dir +from trpc_agent_sdk.skills.tools._common import inline_json_schema_refs +from trpc_agent_sdk.skills.tools._common import require_non_empty +from trpc_agent_sdk.skills.tools._common import set_staged_workspace_dir + + +def test_require_non_empty(): + assert require_non_empty(" ok ", field_name="x") == "ok" + with pytest.raises(ValueError, match="x is required"): + require_non_empty(" ", field_name="x") + + +def test_inline_json_schema_refs(): + schema = {"$defs": {"S": {"type": "string"}}, "properties": {"name": {"$ref": "#/$defs/S"}}} + out = inline_json_schema_refs(schema) + assert "$defs" not in out + assert out["properties"]["name"]["type"] == "string" + + +def test_staged_workspace_dir_round_trip(): + metadata = {} + ctx = MagicMock() + ctx.agent_context.get_metadata = MagicMock(side_effect=lambda key, default=None: metadata.get(key, default)) + ctx.agent_context.with_metadata = MagicMock(side_effect=lambda key, value: metadata.__setitem__(key, value)) + + set_staged_workspace_dir(ctx, "skill-a", "skills/skill-a") + assert get_staged_workspace_dir(ctx, "skill-a") == "skills/skill-a" diff --git a/tests/skills/tools/test_file_stager.py b/tests/skills/tools/test_file_stager.py new file mode 100644 index 000000000..7564ad9ce --- /dev/null +++ b/tests/skills/tools/test_file_stager.py @@ -0,0 +1,142 @@ +# 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 trpc_agent_sdk.skills.tools._file_stager. + +Covers: +- normalize_workspace_skill_dir: valid paths, edge cases, rejected paths +- _normalize_skill_stage_result +- CopySkillStager.stage_skill: validation, delegation +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from trpc_agent_sdk.skills.tools._file_stager import ( + CopySkillStager, + normalize_workspace_skill_dir, + _normalize_skill_stage_result, +) +from trpc_agent_sdk.skills.stager._types import SkillStageResult + + +# --------------------------------------------------------------------------- +# normalize_workspace_skill_dir +# --------------------------------------------------------------------------- + +class TestNormalizeWorkspaceSkillDir: + def test_valid_skills_path(self): + assert normalize_workspace_skill_dir("skills/weather") == "skills/weather" + + def test_valid_work_path(self): + assert normalize_workspace_skill_dir("work/data") == "work/data" + + def test_valid_out_path(self): + assert normalize_workspace_skill_dir("out/results") == "out/results" + + def test_valid_runs_path(self): + assert normalize_workspace_skill_dir("runs/run1") == "runs/run1" + + def test_empty_raises(self): + with pytest.raises(ValueError, match="must not be empty"): + normalize_workspace_skill_dir("") + + def test_whitespace_raises(self): + with pytest.raises(ValueError, match="must not be empty"): + normalize_workspace_skill_dir(" ") + + def test_escaping_workspace_raises(self): + with pytest.raises(ValueError, match="must stay within"): + normalize_workspace_skill_dir("etc/passwd") + + def test_leading_slash_normalized(self): + result = normalize_workspace_skill_dir("/skills/test") + assert result == "skills/test" + + def test_backslash_normalized(self): + result = normalize_workspace_skill_dir("skills\\test") + assert result == "skills/test" + + def test_dot_path(self): + result = normalize_workspace_skill_dir("/") + assert result == "." + + def test_parent_path_raises(self): + with pytest.raises(ValueError, match="must stay within"): + normalize_workspace_skill_dir("../escape") + + +# --------------------------------------------------------------------------- +# _normalize_skill_stage_result +# --------------------------------------------------------------------------- + +class TestNormalizeSkillStageResult: + def test_normalizes(self): + result = SkillStageResult(workspace_skill_dir="skills/test") + normalized = _normalize_skill_stage_result(result) + assert normalized.workspace_skill_dir == "skills/test" + + def test_invalid_raises(self): + result = SkillStageResult(workspace_skill_dir="") + with pytest.raises(ValueError): + _normalize_skill_stage_result(result) + + +# --------------------------------------------------------------------------- +# CopySkillStager +# --------------------------------------------------------------------------- + +class TestCopySkillStager: + async def test_no_repository_raises(self): + stager = CopySkillStager() + from trpc_agent_sdk.skills.stager._types import SkillStageRequest + request = SkillStageRequest( + skill_name="test", + repository=None, + workspace=MagicMock(), + ctx=MagicMock(), + ) + with pytest.raises(ValueError, match="repository"): + await stager.stage_skill(request) + + @patch("trpc_agent_sdk.skills.stager._base_stager.compute_dir_digest", return_value="digest") + async def test_stage_delegates_to_parent(self, mock_digest): + stager = CopySkillStager() + repo = MagicMock() + repo.path = MagicMock(return_value="/skills/test") + runtime = MagicMock() + fs = MagicMock() + runner = MagicMock() + + mock_file = MagicMock() + mock_file.content = json.dumps({"version": 1, "skills": {}}) + fs.collect = AsyncMock(return_value=[mock_file]) + fs.stage_directory = AsyncMock() + fs.put_files = AsyncMock() + + run_result = MagicMock() + run_result.exit_code = 0 + run_result.stderr = "" + runner.run_program = AsyncMock(return_value=run_result) + + runtime.fs = MagicMock(return_value=fs) + runtime.runner = MagicMock(return_value=runner) + repo.workspace_runtime = runtime + repo.get_workspace_runtime = MagicMock(return_value=runtime) + + from trpc_agent_sdk.skills.stager._types import SkillStageRequest + request = SkillStageRequest( + skill_name="test", + repository=repo, + workspace=MagicMock(path="/tmp/ws"), + ctx=MagicMock(), + ) + + result = await stager.stage_skill(request) + assert result.workspace_skill_dir == "skills/test" diff --git a/tests/skills/tools/test_save_artifact.py b/tests/skills/tools/test_save_artifact.py new file mode 100644 index 000000000..837b77167 --- /dev/null +++ b/tests/skills/tools/test_save_artifact.py @@ -0,0 +1,49 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._constants import SKILL_ARTIFACTS_STATE_KEY +from trpc_agent_sdk.skills.tools._save_artifact import SaveArtifactTool +from trpc_agent_sdk.skills.tools._save_artifact import _apply_artifact_state_delta +from trpc_agent_sdk.skills.tools._save_artifact import _artifact_save_skip_reason +from trpc_agent_sdk.skills.tools._save_artifact import _normalize_artifact_path +from trpc_agent_sdk.skills.tools._save_artifact import _normalize_workspace_prefix + + +def test_normalize_workspace_prefix(): + assert _normalize_workspace_prefix("workspace://work/a.txt") == "work/a.txt" + assert _normalize_workspace_prefix("$WORK_DIR/a.txt") == "work/a.txt" + + +def test_normalize_artifact_path_valid_and_invalid(tmp_path: Path): + workspace_root = str(tmp_path) + rel, abs_path = _normalize_artifact_path("work/a.txt", workspace_root) + assert rel == "work/a.txt" + assert abs_path.endswith("work/a.txt") + + with pytest.raises(ValueError, match="stay within the workspace"): + _normalize_artifact_path("../a.txt", workspace_root) + + +def test_artifact_save_skip_reason_and_state_delta(): + ctx = MagicMock() + ctx.artifact_service = object() + ctx.session = object() + ctx.app_name = "app" + ctx.user_id = "u" + ctx.session_id = "s" + ctx.function_call_id = "fc-1" + ctx.actions.state_delta = {} + assert _artifact_save_skip_reason(ctx) == "" + + _apply_artifact_state_delta(ctx, "work/a.txt", 2, "artifact://work/a.txt@2") + value = ctx.actions.state_delta[SKILL_ARTIFACTS_STATE_KEY] + assert value["tool_call_id"] == "fc-1" + assert value["artifacts"][0]["version"] == 2 + + +def test_save_artifact_declaration_name(): + declaration = SaveArtifactTool()._get_declaration() + assert declaration is not None + assert declaration.name == "workspace_save_artifact" diff --git a/tests/skills/tools/test_skill_exec.py b/tests/skills/tools/test_skill_exec.py new file mode 100644 index 000000000..1593e5d95 --- /dev/null +++ b/tests/skills/tools/test_skill_exec.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. + +from unittest.mock import AsyncMock +from unittest.mock import MagicMock + +import pytest +from trpc_agent_sdk.code_executors import DEFAULT_EXEC_YIELD_MS +from trpc_agent_sdk.code_executors import DEFAULT_IO_YIELD_MS +from trpc_agent_sdk.code_executors import DEFAULT_POLL_LINES +from trpc_agent_sdk.code_executors import DEFAULT_SESSION_TTL_SEC +from trpc_agent_sdk.skills.tools._skill_exec import ExecInput +from trpc_agent_sdk.skills.tools._skill_exec import PollSessionTool +from trpc_agent_sdk.skills.tools._skill_exec import SkillExecTool +from trpc_agent_sdk.skills.tools._skill_exec import WriteStdinTool +from trpc_agent_sdk.skills.tools._skill_exec import _close_session +from trpc_agent_sdk.skills.tools._skill_exec import _detect_interaction +from trpc_agent_sdk.skills.tools._skill_exec import _has_selection_items +from trpc_agent_sdk.skills.tools._skill_exec import _last_non_empty_line +from trpc_agent_sdk.skills.tools._skill_exec import create_exec_tools + + +def _make_exec_tool() -> SkillExecTool: + run_tool = MagicMock() + run_tool._repository = MagicMock() + run_tool._timeout = 300.0 + run_tool._resolve_cwd = MagicMock(return_value="skills/test") + run_tool._build_command = MagicMock(return_value=("bash", ["-lc", "echo hello"])) + run_tool._prepare_outputs = AsyncMock(return_value=([], None)) + run_tool._attach_artifacts_if_requested = AsyncMock() + run_tool._merge_manifest_artifact_refs = MagicMock() + return SkillExecTool(run_tool) + + +class TestHelpers: + def test_last_non_empty_line(self): + assert _last_non_empty_line("a\n\nb\n") == "b" + + def test_has_selection_items(self): + assert _has_selection_items("1. a\n2. b") is True + assert _has_selection_items("1. a") is False + + def test_detect_interaction_prompt(self): + ret = _detect_interaction("running", "Enter your name:") + assert ret is not None + assert ret.needs_input is True + + def test_detect_interaction_selection(self): + ret = _detect_interaction("running", "Choose:\n1. A\n2. B\nEnter the number:") + assert ret is not None + assert ret.kind == "selection" + + +class TestModelsAndConstants: + def test_exec_input_defaults(self): + inp = ExecInput(skill="s", command="echo hi") + assert inp.yield_time_ms == 0 + assert inp.poll_lines == 0 + assert inp.tty is False + + def test_default_constants(self): + assert DEFAULT_EXEC_YIELD_MS > 0 + assert DEFAULT_IO_YIELD_MS > 0 + assert DEFAULT_POLL_LINES > 0 + assert DEFAULT_SESSION_TTL_SEC > 0 + + +class TestSessionStore: + @pytest.mark.asyncio + async def test_put_get_remove(self): + tool = _make_exec_tool() + sess = MagicMock() + sess.exited_at = None + sess.proc.state = AsyncMock(return_value=MagicMock(status="running", exit_code=None)) + await tool._put_session("s1", sess) + got = await tool._get_session("s1") + assert got is sess + removed = await tool._remove_session("s1") + assert removed is sess + + +class TestFactoryAndDeclarations: + def test_create_exec_tools(self): + run_tool = MagicMock() + run_tool._repository = MagicMock() + run_tool._timeout = 300.0 + tools = create_exec_tools(run_tool) + assert len(tools) == 4 + assert isinstance(tools[0], SkillExecTool) + assert isinstance(tools[1], WriteStdinTool) + assert isinstance(tools[2], PollSessionTool) + + def test_declaration_names(self): + exec_tool = _make_exec_tool() + assert exec_tool._get_declaration().name == "skill_exec" + assert WriteStdinTool(exec_tool)._get_declaration().name == "skill_write_stdin" + assert PollSessionTool(exec_tool)._get_declaration().name == "skill_poll_session" + + +class TestCloseSession: + @pytest.mark.asyncio + async def test_close_session(self): + sess = MagicMock() + sess.proc.close = AsyncMock() + await _close_session(sess) + sess.proc.close.assert_awaited_once() diff --git a/tests/skills/tools/test_skill_list.py b/tests/skills/tools/test_skill_list.py new file mode 100644 index 000000000..cfece7e94 --- /dev/null +++ b/tests/skills/tools/test_skill_list.py @@ -0,0 +1,45 @@ +# 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 trpc_agent_sdk.skills.tools._skill_list. + +Covers: +- skill_list: returns skill names from repository +- skill_list: raises when repository not found +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills.tools._skill_list import skill_list + + +def _make_ctx(repository=None): + ctx = MagicMock() + ctx.agent_context.get_metadata = MagicMock(return_value=repository) + return ctx + + +class TestSkillList: + def test_returns_skill_names(self): + repo = MagicMock() + repo.skill_list = MagicMock(return_value=["skill-a", "skill-b"]) + ctx = _make_ctx(repository=repo) + result = skill_list(ctx) + assert result == ["skill-a", "skill-b"] + + def test_empty_repository(self): + repo = MagicMock() + repo.skill_list = MagicMock(return_value=[]) + ctx = _make_ctx(repository=repo) + assert skill_list(ctx) == [] + + def test_no_repository_raises(self): + ctx = _make_ctx(repository=None) + with pytest.raises(ValueError, match="repository not found"): + skill_list(ctx) diff --git a/tests/skills/tools/test_skill_list_docs.py b/tests/skills/tools/test_skill_list_docs.py new file mode 100644 index 000000000..26999d994 --- /dev/null +++ b/tests/skills/tools/test_skill_list_docs.py @@ -0,0 +1,66 @@ +# 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 trpc_agent_sdk.skills.tools._skill_list_docs. + +Covers: +- skill_list_docs: returns docs and body_loaded status +- skill_list_docs: handles missing skill +- skill_list_docs: raises when no repository +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._types import Skill, SkillResource, SkillSummary +from trpc_agent_sdk.skills.tools._skill_list_docs import skill_list_docs + + +def _make_ctx(repository=None): + ctx = MagicMock() + ctx.agent_context.get_metadata = MagicMock(return_value=repository) + return ctx + + +class TestSkillListDocs: + def test_returns_docs(self): + skill = Skill( + summary=SkillSummary(name="test"), + body="# Body", + resources=[ + SkillResource(path="guide.md", content="guide"), + SkillResource(path="api.md", content="api"), + ], + ) + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + result = skill_list_docs(ctx, "test") + assert result == ["guide.md", "api.md"] + + def test_no_body(self): + skill = Skill(summary=SkillSummary(name="test"), body="") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + result = skill_list_docs(ctx, "test") + assert result == [] + + def test_skill_not_found(self): + repo = MagicMock() + repo.get = MagicMock(side_effect=ValueError("not found")) + ctx = _make_ctx(repository=repo) + + with pytest.raises(ValueError, match="unknown skill"): + skill_list_docs(ctx, "nonexistent") + + def test_no_repository_raises(self): + ctx = _make_ctx(repository=None) + assert skill_list_docs(ctx, "test") == [] diff --git a/tests/skills/tools/test_skill_list_tool.py b/tests/skills/tools/test_skill_list_tool.py new file mode 100644 index 000000000..220524a8a --- /dev/null +++ b/tests/skills/tools/test_skill_list_tool.py @@ -0,0 +1,64 @@ +# 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 trpc_agent_sdk.skills.tools._skill_list_tool.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._types import Skill, SkillSummary +from trpc_agent_sdk.skills.tools._skill_list_tool import ( + skill_list_tools, +) + + +# --------------------------------------------------------------------------- +# skill_list_tools +# --------------------------------------------------------------------------- + +def _make_ctx(repository=None): + ctx = MagicMock() + ctx.agent_context.get_metadata = MagicMock(return_value=repository) + return ctx + + +class TestSkillListTools: + def test_returns_tools(self): + skill = Skill( + summary=SkillSummary(name="test"), + body="Command:\n python run.py\n\nOverview", + tools=["get_weather", "get_data"], + ) + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + result = skill_list_tools(ctx, "test") + assert result["available_tools"] == ["get_weather", "get_data"] + + def test_skill_not_found(self): + repo = MagicMock() + repo.get = MagicMock(return_value=None) + ctx = _make_ctx(repository=repo) + + result = skill_list_tools(ctx, "nonexistent") + assert result == {"available_tools": []} + + def test_no_repository_raises(self): + ctx = _make_ctx(repository=None) + with pytest.raises(ValueError, match="repository not found"): + skill_list_tools(ctx, "test") + + def test_no_tools_or_examples(self): + skill = Skill(summary=SkillSummary(name="test"), body="# Overview\n") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + result = skill_list_tools(ctx, "test") + assert result["available_tools"] == [] diff --git a/tests/skills/tools/test_skill_load.py b/tests/skills/tools/test_skill_load.py new file mode 100644 index 000000000..4d7e9d18d --- /dev/null +++ b/tests/skills/tools/test_skill_load.py @@ -0,0 +1,241 @@ +# 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 trpc_agent_sdk.skills.tools._skill_load. + +Covers: +- _set_state_delta +- _set_state_delta_for_skill_load: docs and include_all_docs +- _set_state_delta_for_skill_tools +- skill_load: success, not found, with tools, with docs +""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.skills._common import docs_state_key +from trpc_agent_sdk.skills._common import loaded_state_key +from trpc_agent_sdk.skills._common import set_state_delta +from trpc_agent_sdk.skills._common import tool_state_key +from trpc_agent_sdk.skills._constants import SKILL_REPOSITORY_KEY +from trpc_agent_sdk.skills._types import Skill, SkillSummary +from trpc_agent_sdk.skills.tools._skill_load import ( + SkillLoadTool, +) +from trpc_agent_sdk.skills.stager import SkillStageResult + + +def _make_ctx(repository=None): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_context.get_metadata = MagicMock(return_value=repository) + ctx.agent_name = "" + return ctx + + +def _set_state_delta_for_skill_load(ctx, skill_name: str, docs: list[str], include_all_docs: bool = False): + set_state_delta(ctx, loaded_state_key(ctx, skill_name), True) + set_state_delta( + ctx, + docs_state_key(ctx, skill_name), + "*" if include_all_docs else json.dumps(docs or []), + ) + + +def _set_state_delta_for_skill_tools(ctx, skill_name: str, tools: list[str]): + set_state_delta(ctx, tool_state_key(ctx, skill_name), json.dumps(tools or [])) + + +def _set_state_delta(ctx, key: str, value: str): + set_state_delta(ctx, key, value) + + +def skill_load(ctx, skill_name: str, docs: list[str] | None = None, include_all_docs: bool = False) -> str: + repository = ctx.agent_context.get_metadata(SKILL_REPOSITORY_KEY) + if repository is None: + raise ValueError("repository not found") + tool = SkillLoadTool(repository=repository) + with patch.object(SkillLoadTool, "_ensure_staged", new=AsyncMock(return_value=None)): + return asyncio.run( + tool._run_async_impl( + tool_context=ctx, + args={ + "skill_name": skill_name, + "docs": docs or [], + "include_all_docs": include_all_docs, + }, + )) + + +# --------------------------------------------------------------------------- +# _set_state_delta +# --------------------------------------------------------------------------- + +class TestSetStateDelta: + def test_sets_value(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + _set_state_delta(ctx, "key", "value") + assert ctx.actions.state_delta["key"] == "value" + + +# --------------------------------------------------------------------------- +# _set_state_delta_for_skill_load +# --------------------------------------------------------------------------- + +class TestSetStateDeltaForSkillLoad: + def test_sets_loaded_flag(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_name = "" + _set_state_delta_for_skill_load(ctx, "test-skill", []) + assert ctx.actions.state_delta[loaded_state_key(ctx, "test-skill")] is True + + def test_sets_docs_as_json(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_name = "" + _set_state_delta_for_skill_load(ctx, "test-skill", ["doc1.md", "doc2.md"]) + docs_value = ctx.actions.state_delta[docs_state_key(ctx, "test-skill")] + assert json.loads(docs_value) == ["doc1.md", "doc2.md"] + + def test_include_all_docs_sets_star(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_name = "" + _set_state_delta_for_skill_load(ctx, "test-skill", [], include_all_docs=True) + assert ctx.actions.state_delta[docs_state_key(ctx, "test-skill")] == "*" + + +class TestWorkspaceRuntimeResolver: + @pytest.mark.asyncio + async def test_ensure_staged_uses_repository_runtime(self): + repo_runtime = MagicMock() + repo = MagicMock() + repo.workspace_runtime = repo_runtime + + resolved_runtime = MagicMock() + manager = MagicMock() + manager.create_workspace = AsyncMock(return_value=MagicMock()) + resolved_runtime.manager = MagicMock(return_value=manager) + repo.get_workspace_runtime = MagicMock(return_value=resolved_runtime) + + stager = MagicMock() + stager.stage_skill = AsyncMock(return_value=SkillStageResult(workspace_skill_dir="skills/test-skill")) + + ctx = _make_ctx(repo) + tool = SkillLoadTool( + repository=repo, + skill_stager=stager, + create_ws_name_cb=lambda _: "ws", + ) + + await tool._ensure_staged(ctx=ctx, skill_name="test-skill") + + resolved_runtime.manager.assert_called_once_with(ctx) + repo.get_workspace_runtime.assert_called_once_with(ctx) + repo_runtime.manager.assert_not_called() + + +# --------------------------------------------------------------------------- +# _set_state_delta_for_skill_tools +# --------------------------------------------------------------------------- + +class TestSetStateDeltaForSkillTools: + def test_sets_tools_as_json(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_name = "" + _set_state_delta_for_skill_tools(ctx, "test-skill", ["tool_a", "tool_b"]) + tools_value = ctx.actions.state_delta[tool_state_key(ctx, "test-skill")] + assert json.loads(tools_value) == ["tool_a", "tool_b"] + + def test_empty_tools(self): + ctx = MagicMock() + ctx.actions.state_delta = {} + ctx.agent_name = "" + _set_state_delta_for_skill_tools(ctx, "test-skill", []) + tools_value = ctx.actions.state_delta[tool_state_key(ctx, "test-skill")] + assert json.loads(tools_value) == [] + + +# --------------------------------------------------------------------------- +# skill_load +# --------------------------------------------------------------------------- + +class TestSkillLoad: + def test_load_success(self): + skill = Skill(summary=SkillSummary(name="test"), body="# Test Body") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + result = skill_load(ctx, "test") + assert "loaded" in result + assert ctx.actions.state_delta[loaded_state_key(ctx, "test")] is True + + def test_load_not_found(self): + repo = MagicMock() + repo.get = MagicMock(side_effect=ValueError("not found")) + ctx = _make_ctx(repository=repo) + + with pytest.raises(ValueError, match="not found"): + skill_load(ctx, "nonexistent") + + def test_load_no_repository_raises(self): + ctx = _make_ctx(repository=None) + with pytest.raises(ValueError, match="repository not found"): + skill_load(ctx, "test") + + def test_load_with_tools_sets_tools_state(self): + skill = Skill( + summary=SkillSummary(name="test"), + body="# Body", + tools=["get_weather", "get_data"], + ) + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + skill_load(ctx, "test") + tools_key = tool_state_key(ctx, "test") + assert tools_key in ctx.actions.state_delta + assert json.loads(ctx.actions.state_delta[tools_key]) == ["get_weather", "get_data"] + + def test_load_without_tools_does_not_set_tools_state(self): + skill = Skill(summary=SkillSummary(name="test"), body="# Body") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + skill_load(ctx, "test") + assert tool_state_key(ctx, "test") not in ctx.actions.state_delta + + def test_load_with_docs(self): + skill = Skill(summary=SkillSummary(name="test"), body="# Body") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + skill_load(ctx, "test", docs=["doc1.md"]) + docs_key = docs_state_key(ctx, "test") + assert json.loads(ctx.actions.state_delta[docs_key]) == ["doc1.md"] + + def test_load_include_all_docs(self): + skill = Skill(summary=SkillSummary(name="test"), body="# Body") + repo = MagicMock() + repo.get = MagicMock(return_value=skill) + ctx = _make_ctx(repository=repo) + + skill_load(ctx, "test", include_all_docs=True) + docs_key = docs_state_key(ctx, "test") + assert ctx.actions.state_delta[docs_key] == "*" diff --git a/tests/skills/tools/test_skill_run.py b/tests/skills/tools/test_skill_run.py new file mode 100644 index 000000000..a6c8e438c --- /dev/null +++ b/tests/skills/tools/test_skill_run.py @@ -0,0 +1,137 @@ +# 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. + +import os +from unittest.mock import MagicMock + +import pytest +from trpc_agent_sdk.skills._common import loaded_state_key +from trpc_agent_sdk.skills.tools._common import inline_json_schema_refs +from trpc_agent_sdk.skills.tools._skill_run import ArtifactInfo +from trpc_agent_sdk.skills.tools._skill_run import SkillRunFile +from trpc_agent_sdk.skills.tools._skill_run import SkillRunInput +from trpc_agent_sdk.skills.tools._skill_run import SkillRunOutput +from trpc_agent_sdk.skills.tools._skill_run import SkillRunTool +from trpc_agent_sdk.skills.tools._skill_run import _build_editor_wrapper_script +from trpc_agent_sdk.skills.tools._skill_run import _filter_failed_empty_outputs +from trpc_agent_sdk.skills.tools._skill_run import _is_text_mime +from trpc_agent_sdk.skills.tools._skill_run import _select_primary_output +from trpc_agent_sdk.skills.tools._skill_run import _should_inline_file_content +from trpc_agent_sdk.skills.tools._skill_run import _split_command_line +from trpc_agent_sdk.skills.tools._skill_run import _truncate_output +from trpc_agent_sdk.skills.tools._skill_run import _workspace_ref + + +def _make_tool() -> SkillRunTool: + repo = MagicMock() + repo.workspace_runtime = MagicMock() + return SkillRunTool(repository=repo) + + +class TestSchemaHelpers: + def test_inline_json_schema_refs(self): + schema = {"$defs": {"X": {"type": "string"}}, "properties": {"x": {"$ref": "#/$defs/X"}}} + out = inline_json_schema_refs(schema) + assert "$defs" not in out + assert out["properties"]["x"]["type"] == "string" + + +class TestModuleHelpers: + def test_is_text_mime(self): + assert _is_text_mime("text/plain") is True + assert _is_text_mime("application/json") is True + assert _is_text_mime("image/png") is False + + def test_should_inline_file_content(self): + from trpc_agent_sdk.code_executors import CodeFile + f = CodeFile(name="a.txt", content="ok", mime_type="text/plain", size_bytes=2) + assert _should_inline_file_content(f) is True + + def test_truncate_output(self): + s, truncated = _truncate_output("x" * 20000) + assert truncated is True + assert len(s) <= 16 * 1024 + + def test_workspace_ref(self): + assert _workspace_ref("a.txt") == "workspace://a.txt" + + def test_filter_failed_empty_outputs(self): + files = [SkillRunFile(name="a.txt", content="", size_bytes=0)] + kept, warns = _filter_failed_empty_outputs(1, False, files) + assert kept == [] + assert warns + + def test_select_primary_output(self): + files = [ + SkillRunFile(name="b.txt", content="2", mime_type="text/plain"), + SkillRunFile(name="a.txt", content="1", mime_type="text/plain"), + ] + best = _select_primary_output(files) + assert best is not None + assert best.name == "a.txt" + + def test_split_command_line(self): + assert _split_command_line("python run.py") == ["python", "run.py"] + with pytest.raises(ValueError): + _split_command_line("a | b") + + def test_build_editor_wrapper_script(self): + script = _build_editor_wrapper_script("/tmp/file") + assert script.startswith("#!/bin/sh") + assert "/tmp/file" in script + + +class TestModels: + def test_run_models(self): + inp = SkillRunInput(skill="s", command="echo hi") + out = SkillRunOutput() + art = ArtifactInfo(name="a.txt", version=1) + assert inp.skill == "s" + assert out.exit_code == 0 + assert art.version == 1 + + +class TestSkillRunToolBasics: + def test_resolve_cwd(self): + tool = _make_tool() + assert tool._resolve_cwd("", "skills/x") == "skills/x" + assert tool._resolve_cwd("sub", "skills/x") == os.path.join("skills/x", "sub") + + def test_build_command(self): + tool = _make_tool() + cmd, args = tool._build_command("python run.py", "/tmp/ws", "skills/x") + assert cmd == "bash" + assert "-c" in args + + def test_get_repository(self): + repo = MagicMock() + repo.workspace_runtime = MagicMock() + tool = SkillRunTool(repository=repo) + ctx = MagicMock() + assert tool._get_repository(ctx) is repo + + def test_repository_get_workspace_runtime_is_used(self): + repo_runtime = MagicMock() + repo = MagicMock() + repo.workspace_runtime = repo_runtime + resolved_runtime = MagicMock() + repo.get_workspace_runtime = MagicMock(return_value=resolved_runtime) + ctx = MagicMock() + + tool = SkillRunTool(repository=repo) + + assert tool._get_repository(ctx).get_workspace_runtime(ctx) is resolved_runtime + repo.get_workspace_runtime.assert_called_once_with(ctx) + + def test_is_skill_loaded(self): + tool = _make_tool() + ctx = MagicMock() + ctx.agent_name = "" + ctx.actions = MagicMock() + key = loaded_state_key(ctx, "test") + ctx.actions.state_delta = {key: True} + ctx.session_state = {} + assert tool._is_skill_loaded(ctx, "test") is True diff --git a/tests/skills/tools/test_skill_select_docs.py b/tests/skills/tools/test_skill_select_docs.py new file mode 100644 index 000000000..1d8577e6c --- /dev/null +++ b/tests/skills/tools/test_skill_select_docs.py @@ -0,0 +1,108 @@ +# 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 trpc_agent_sdk.skills.tools._skill_select_docs. + +Covers: +- SkillSelectDocsResult: alias field mapping +- skill_select_docs: replace, add, clear modes +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._constants import SKILL_CONFIG_KEY +from trpc_agent_sdk.skills.tools._skill_select_docs import ( + SkillSelectDocsResult, + skill_select_docs, +) +from trpc_agent_sdk.skills._common import docs_state_key +from trpc_agent_sdk.skills._skill_config import DEFAULT_SKILL_CONFIG + + +def _make_ctx(state_delta=None, session_state=None): + ctx = MagicMock() + ctx.actions.state_delta = state_delta or {} + ctx.session_state = session_state or {} + ctx.agent_name = "" + ctx.agent_context.get_metadata = MagicMock( + side_effect=lambda key, default=None: DEFAULT_SKILL_CONFIG if key == SKILL_CONFIG_KEY else default) + return ctx + + +# --------------------------------------------------------------------------- +# SkillSelectDocsResult +# --------------------------------------------------------------------------- + +class TestSkillSelectDocsResult: + def test_alias_selected_items(self): + result = SkillSelectDocsResult( + skill="test", + selected_items=["doc1.md", "doc2.md"], + include_all=False, + ) + assert result.selected_docs == ["doc1.md", "doc2.md"] + assert result.include_all_docs is False + + def test_alias_include_all(self): + result = SkillSelectDocsResult( + skill="test", + selected_items=[], + include_all=True, + ) + assert result.include_all_docs is True + assert result.selected_docs == [] + + def test_direct_field_setting(self): + result = SkillSelectDocsResult( + skill="test", + selected_docs=["a.md"], + include_all_docs=True, + ) + assert result.selected_docs == ["a.md"] + assert result.include_all_docs is True + + +# --------------------------------------------------------------------------- +# skill_select_docs +# --------------------------------------------------------------------------- + +class TestSkillSelectDocs: + def test_replace_mode(self): + ctx = _make_ctx() + result = skill_select_docs(ctx, "test-skill", docs=["doc1.md", "doc2.md"], mode="replace") + assert result.skill == "test-skill" + assert result.mode == "replace" + assert result.selected_docs == ["doc1.md", "doc2.md"] + + def test_add_mode(self): + ctx = _make_ctx() + ctx.session_state = {docs_state_key(ctx, "test-skill"): json.dumps(["existing.md"])} + result = skill_select_docs(ctx, "test-skill", docs=["new.md"], mode="add") + assert result.mode == "add" + assert "existing.md" in result.selected_docs + assert "new.md" in result.selected_docs + + def test_clear_mode(self): + ctx = _make_ctx() + ctx.session_state = {docs_state_key(ctx, "test-skill"): json.dumps(["some.md"])} + result = skill_select_docs(ctx, "test-skill", mode="clear") + assert result.mode == "clear" + assert result.selected_docs == [] + + def test_include_all_docs(self): + ctx = _make_ctx() + result = skill_select_docs(ctx, "test-skill", include_all_docs=True, mode="replace") + assert result.include_all_docs is True + + def test_updates_state_delta(self): + ctx = _make_ctx() + skill_select_docs(ctx, "test-skill", docs=["a.md"], mode="replace") + key = docs_state_key(ctx, "test-skill") + assert key in ctx.actions.state_delta diff --git a/tests/skills/tools/test_skill_select_tools.py b/tests/skills/tools/test_skill_select_tools.py new file mode 100644 index 000000000..9b9fe98d5 --- /dev/null +++ b/tests/skills/tools/test_skill_select_tools.py @@ -0,0 +1,106 @@ +# 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 trpc_agent_sdk.skills.tools._skill_select_tools. + +Covers: +- SkillSelectToolsResult: alias field mapping +- skill_select_tools: replace, add, clear modes +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.skills._constants import SKILL_CONFIG_KEY +from trpc_agent_sdk.skills.tools._skill_select_tools import ( + SkillSelectToolsResult, + skill_select_tools, +) +from trpc_agent_sdk.skills._common import tool_state_key +from trpc_agent_sdk.skills._skill_config import DEFAULT_SKILL_CONFIG + + +def _make_ctx(state_delta=None, session_state=None): + ctx = MagicMock() + ctx.actions.state_delta = state_delta or {} + ctx.session_state = session_state or {} + ctx.agent_name = "" + ctx.agent_context.get_metadata = MagicMock( + side_effect=lambda key, default=None: DEFAULT_SKILL_CONFIG if key == SKILL_CONFIG_KEY else default) + return ctx + + +# --------------------------------------------------------------------------- +# SkillSelectToolsResult +# --------------------------------------------------------------------------- + +class TestSkillSelectToolsResult: + def test_alias_selected_items(self): + result = SkillSelectToolsResult( + skill="test", + selected_items=["tool_a", "tool_b"], + include_all=False, + ) + assert result.selected_tools == ["tool_a", "tool_b"] + assert result.include_all_tools is False + + def test_alias_include_all(self): + result = SkillSelectToolsResult( + skill="test", + selected_items=[], + include_all=True, + ) + assert result.include_all_tools is True + + def test_direct_field_setting(self): + result = SkillSelectToolsResult( + skill="test", + selected_tools=["t1"], + include_all_tools=True, + ) + assert result.selected_tools == ["t1"] + + +# --------------------------------------------------------------------------- +# skill_select_tools +# --------------------------------------------------------------------------- + +class TestSkillSelectTools: + def test_replace_mode(self): + ctx = _make_ctx() + result = skill_select_tools(ctx, "test-skill", tools=["tool_a", "tool_b"], mode="replace") + assert result.skill == "test-skill" + assert result.mode == "replace" + assert result.selected_tools == ["tool_a", "tool_b"] + + def test_add_mode(self): + ctx = _make_ctx() + ctx.session_state = {tool_state_key(ctx, "test-skill"): json.dumps(["existing_tool"])} + result = skill_select_tools(ctx, "test-skill", tools=["new_tool"], mode="add") + assert result.mode == "add" + assert "existing_tool" in result.selected_tools + assert "new_tool" in result.selected_tools + + def test_clear_mode(self): + ctx = _make_ctx() + ctx.session_state = {tool_state_key(ctx, "test-skill"): json.dumps(["tool"])} + result = skill_select_tools(ctx, "test-skill", mode="clear") + assert result.mode == "clear" + assert result.selected_tools == [] + + def test_include_all_tools(self): + ctx = _make_ctx() + result = skill_select_tools(ctx, "test-skill", include_all_tools=True, mode="replace") + assert result.include_all_tools is True + + def test_updates_state_delta(self): + ctx = _make_ctx() + skill_select_tools(ctx, "test-skill", tools=["t1"], mode="replace") + key = tool_state_key(ctx, "test-skill") + assert key in ctx.actions.state_delta diff --git a/tests/skills/tools/test_workspace_exec.py b/tests/skills/tools/test_workspace_exec.py new file mode 100644 index 000000000..467e6a95d --- /dev/null +++ b/tests/skills/tools/test_workspace_exec.py @@ -0,0 +1,34 @@ +import pytest + +from trpc_agent_sdk.code_executors import PROGRAM_STATUS_RUNNING +from trpc_agent_sdk.code_executors import ProgramPoll +from trpc_agent_sdk.skills.tools._workspace_exec import _combine_output +from trpc_agent_sdk.skills.tools._workspace_exec import _exec_timeout_seconds +from trpc_agent_sdk.skills.tools._workspace_exec import _exec_yield_seconds +from trpc_agent_sdk.skills.tools._workspace_exec import _normalize_cwd +from trpc_agent_sdk.skills.tools._workspace_exec import _poll_output +from trpc_agent_sdk.skills.tools._workspace_exec import _write_yield_seconds + + +def test_normalize_cwd(): + assert _normalize_cwd("") == "." + assert _normalize_cwd("work/demo") == "work/demo" + with pytest.raises(ValueError, match="within the workspace"): + _normalize_cwd("../demo") + + +def test_timeout_and_yield_helpers(): + assert _exec_timeout_seconds(0) > 0 + assert _exec_timeout_seconds(3) == 3.0 + assert _exec_yield_seconds(background=True, raw_ms=None) == 0.0 + assert _exec_yield_seconds(background=False, raw_ms=100) == 0.1 + assert _write_yield_seconds(None) > 0.0 + assert _write_yield_seconds(-1) == 0.0 + + +def test_poll_output_and_combine_output(): + poll = ProgramPoll(status=PROGRAM_STATUS_RUNNING, output="ok", offset=1, next_offset=2) + out = _poll_output("sid-1", poll) + assert out["session_id"] == "sid-1" + assert out["output"] == "ok" + assert _combine_output("a", "b") == "ab" diff --git a/tests/storage/__init__.py b/tests/storage/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/tests/storage/__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 Apache-2.0. diff --git a/tests/storage/test_constants.py b/tests/storage/test_constants.py new file mode 100644 index 000000000..937b46e55 --- /dev/null +++ b/tests/storage/test_constants.py @@ -0,0 +1,32 @@ +# 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. +"""Tests for storage constants.""" + +from trpc_agent_sdk.storage._constants import DEFAULT_MAX_KEY_LENGTH +from trpc_agent_sdk.storage._constants import DEFAULT_MAX_VARCHAR_LENGTH + + +class TestConstants: + + def test_default_max_key_length_value(self): + assert DEFAULT_MAX_KEY_LENGTH == 128 + + def test_default_max_varchar_length_value(self): + assert DEFAULT_MAX_VARCHAR_LENGTH == 256 + + def test_constants_are_int(self): + assert isinstance(DEFAULT_MAX_KEY_LENGTH, int) + assert isinstance(DEFAULT_MAX_VARCHAR_LENGTH, int) + + +class TestConstantsReexport: + + def test_reexported_from_package(self): + from trpc_agent_sdk.storage import DEFAULT_MAX_KEY_LENGTH as pkg_key_len + from trpc_agent_sdk.storage import DEFAULT_MAX_VARCHAR_LENGTH as pkg_varchar_len + + assert pkg_key_len == DEFAULT_MAX_KEY_LENGTH + assert pkg_varchar_len == DEFAULT_MAX_VARCHAR_LENGTH diff --git a/tests/storage/test_db.py b/tests/storage/test_db.py new file mode 100644 index 000000000..f8cc7b844 --- /dev/null +++ b/tests/storage/test_db.py @@ -0,0 +1,105 @@ +# 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. +"""Tests for BaseStorage abstract class.""" + +from abc import ABC + +import pytest + +from trpc_agent_sdk.storage._db import BaseStorage + + +class TestBaseStorageAbstract: + + def test_cannot_instantiate_directly(self): + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + BaseStorage() + + def test_is_abstract_class(self): + assert issubclass(BaseStorage, ABC) + + def test_has_abstract_methods(self): + abstract_methods = BaseStorage.__abstractmethods__ + expected = {"add", "delete", "query", "get", "commit", "refresh", "close"} + assert abstract_methods == expected + + +class TestBaseStorageSubclass: + + def test_incomplete_subclass_raises(self): + class PartialStorage(BaseStorage): + async def add(self, db, data): + pass + + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + PartialStorage() + + def test_complete_subclass_instantiates(self): + class ConcreteStorage(BaseStorage): + async def add(self, db, data): + pass + + async def delete(self, db, key): + pass + + async def query(self, db, key, filters, limit=None): + pass + + async def get(self, db, key): + pass + + async def commit(self, db, data): + pass + + async def refresh(self, db, data): + pass + + async def close(self): + pass + + storage = ConcreteStorage() + assert isinstance(storage, BaseStorage) + + @pytest.mark.asyncio + async def test_subclass_methods_callable(self): + class ConcreteStorage(BaseStorage): + async def add(self, db, data): + return "added" + + async def delete(self, db, key): + return "deleted" + + async def query(self, db, key, filters, limit=None): + return ["result"] + + async def get(self, db, key): + return "value" + + async def commit(self, db, data): + return "committed" + + async def refresh(self, db, data): + return "refreshed" + + async def close(self): + return "closed" + + storage = ConcreteStorage() + assert await storage.add(None, None) == "added" + assert await storage.delete(None, None) == "deleted" + assert await storage.query(None, None, []) == ["result"] + assert await storage.get(None, None) == "value" + assert await storage.commit(None, None) == "committed" + assert await storage.refresh(None, None) == "refreshed" + assert await storage.close() == "closed" + + +class TestBaseStorageReexport: + + def test_reexported_from_package(self): + from trpc_agent_sdk.storage import BaseStorage as PkgBaseStorage + + assert PkgBaseStorage is BaseStorage diff --git a/tests/storage/test_redis.py b/tests/storage/test_redis.py new file mode 100644 index 000000000..2fbcd923b --- /dev/null +++ b/tests/storage/test_redis.py @@ -0,0 +1,817 @@ +# 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. +"""Tests for Redis storage implementation.""" +import json +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from trpc_agent_sdk.storage import EXPIRE_METHOD +from trpc_agent_sdk.storage import RedisAsyncContextManager +from trpc_agent_sdk.storage import RedisCommand +from trpc_agent_sdk.storage import RedisCondition +from trpc_agent_sdk.storage import RedisExpire +from trpc_agent_sdk.storage import RedisStorage +from trpc_agent_sdk.types import Ttl + + +class TestRedisStorage: + """Test suite for RedisStorage class.""" + + @pytest.fixture + def redis_url(self): + """Redis URL fixture.""" + return "redis://localhost:6379/0" + + @pytest.fixture + def sync_storage(self, redis_url): + """Synchronous Redis storage fixture.""" + return RedisStorage(redis_url=redis_url, is_async=False) + + @pytest.fixture + def async_storage(self, redis_url): + """Asynchronous Redis storage fixture.""" + return RedisStorage(redis_url=redis_url, is_async=True) + + def test_init(self, redis_url): + """Test RedisStorage initialization.""" + storage = RedisStorage(redis_url=redis_url, is_async=True, max_connections=10, decode_responses=True) + + assert storage._redis_url == redis_url + assert storage._is_async is True + assert storage._kwargs == {"max_connections": 10, "decode_responses": True} + assert storage._redis_pool is None + + @pytest.mark.asyncio + async def test_create_redis_engine_async(self, async_storage): + """Test creating async Redis connection pool.""" + with patch('trpc_agent_sdk.storage._redis.AsyncConnectionPool') as mock_pool: + mock_pool.from_url.return_value = MagicMock() + + await async_storage.create_redis_engine() + + mock_pool.from_url.assert_called_once_with(async_storage._redis_url) + assert async_storage._redis_pool is not None + + @pytest.mark.asyncio + async def test_create_redis_engine_sync(self, sync_storage): + """Test creating sync Redis connection pool.""" + with patch('trpc_agent_sdk.storage._redis.SyncConnectionPool') as mock_pool: + mock_pool.from_url.return_value = MagicMock() + + await sync_storage.create_redis_engine() + + mock_pool.from_url.assert_called_once_with(sync_storage._redis_url) + assert sync_storage._redis_pool is not None + + @pytest.mark.asyncio + async def test_create_redis_engine_already_exists(self, async_storage): + """Test creating Redis engine when pool already exists.""" + async_storage._redis_pool = MagicMock() + + with patch('trpc_agent_sdk.storage._redis.AsyncConnectionPool') as mock_pool: + await async_storage.create_redis_engine() + # Should not create new pool + mock_pool.from_url.assert_not_called() + + @pytest.mark.asyncio + async def test_create_redis_engine_error(self, async_storage): + """Test error handling when creating Redis engine.""" + with patch('trpc_agent_sdk.storage._redis.AsyncConnectionPool.from_url') as mock_from_url: + mock_from_url.side_effect = Exception("Connection error") + + with pytest.raises(ValueError, match="Failed to create Redis connection pool"): + await async_storage.create_redis_engine() + + @pytest.mark.asyncio + async def test_create_redis_session_async(self, async_storage): + """Test creating async Redis session.""" + from redis.asyncio import ConnectionPool as AsyncConnectionPool + mock_pool = AsyncMock(spec=AsyncConnectionPool) + async_storage._redis_pool = mock_pool + + with patch('trpc_agent_sdk.storage._redis.AsyncRedis') as mock_redis: + mock_redis.return_value = MagicMock() + session = await async_storage.create_redis_session() + + mock_redis.assert_called_once_with(connection_pool=mock_pool) + assert session is not None + + @pytest.mark.asyncio + async def test_create_redis_session_sync(self, sync_storage): + """Test creating sync Redis session.""" + mock_pool = MagicMock() + sync_storage._redis_pool = mock_pool + + with patch('trpc_agent_sdk.storage._redis.SyncRedis') as mock_redis: + mock_redis.return_value = MagicMock() + session = await sync_storage.create_redis_session() + + mock_redis.assert_called_once_with(connection_pool=mock_pool) + assert session is not None + + @pytest.mark.asyncio + async def test_create_redis_session_no_pool(self, async_storage): + """Test creating session without pool raises error.""" + async_storage._redis_pool = None + + with patch.object(async_storage, 'create_redis_engine') as mock_create: + mock_create.return_value = None + + with pytest.raises(ValueError, match="Redis connection pool not initialized"): + await async_storage.create_redis_session() + + def test_create_db_session(self, async_storage): + """Test creating database session context manager.""" + ctx = async_storage.create_db_session() + assert isinstance(ctx, RedisAsyncContextManager) + assert ctx.redis_storage is async_storage + + def test_serialize_value_primitives(self, sync_storage): + """Test serializing primitive values.""" + assert sync_storage._serialize_value("test") == "test" + assert sync_storage._serialize_value(123) == "123" + assert sync_storage._serialize_value(45.67) == "45.67" + assert sync_storage._serialize_value(True) == "True" + assert sync_storage._serialize_value(False) == "False" + + def test_serialize_value_complex(self, sync_storage): + """Test serializing complex values.""" + data = {"key": "value", "count": 42} + result = sync_storage._serialize_value(data) + assert json.loads(result) == data + + data = ["item1", "item2", 3] + result = sync_storage._serialize_value(data) + assert json.loads(result) == data + + def test_deserialize_value_none(self, sync_storage): + """Test deserializing None value.""" + assert sync_storage._deserialize_value(None) is None + + def test_deserialize_value_json(self, sync_storage): + """Test deserializing JSON values.""" + data = {"key": "value", "count": 42} + serialized = json.dumps(data).encode('utf-8') + result = sync_storage._deserialize_value(serialized) + assert result == data + + def test_deserialize_value_string(self, sync_storage): + """Test deserializing string values.""" + result = sync_storage._deserialize_value(b"simple string") + assert result == "simple string" + + def test_deserialize_value_unicode_error(self, sync_storage): + """Test deserializing with unicode error.""" + # Invalid UTF-8 bytes + invalid_bytes = b'\xff\xfe' + result = sync_storage._deserialize_value(invalid_bytes) + assert result == invalid_bytes + + @pytest.mark.asyncio + async def test_add_valid_method(self, async_storage): + """Test adding data with valid method.""" + mock_conn = AsyncMock() + command = RedisCommand(method='set', args=('key', 'value')) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.return_value = None + await async_storage.add(mock_conn, command) + mock_execute.assert_called_once_with(mock_conn, command) + + @pytest.mark.asyncio + async def test_add_invalid_method(self, async_storage): + """Test adding data with invalid method.""" + mock_conn = AsyncMock() + command = RedisCommand(method='get', args=('key', )) + + with pytest.raises(ValueError, match="Invalid Redis set method"): + await async_storage.add(mock_conn, command) + + @pytest.mark.asyncio + async def test_add_all_expire_methods(self, async_storage): + """Test all valid EXPIRE methods.""" + mock_conn = AsyncMock() + + for method in EXPIRE_METHOD: + command = RedisCommand(method=method, args=('key', 'value')) + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.return_value = None + await async_storage.add(mock_conn, command) + mock_execute.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_async(self, async_storage): + """Test deleting data with async connection.""" + from redis.asyncio import Redis as AsyncRedis + + mock_conn = AsyncMock(spec=AsyncRedis) + mock_conn.delete = AsyncMock() + + await async_storage.delete(mock_conn, 'test_key') + mock_conn.delete.assert_called_once_with('test_key') + + @pytest.mark.asyncio + async def test_delete_sync(self, sync_storage): + """Test deleting data with sync connection.""" + mock_conn = MagicMock() + mock_conn.delete = MagicMock(return_value=1) + + await sync_storage.delete(mock_conn, 'test_key') + mock_conn.delete.assert_called_once_with('test_key') + + @pytest.mark.asyncio + async def test_get_valid_method(self, async_storage): + """Test getting data with valid method.""" + mock_conn = AsyncMock() + command = RedisCommand(method='get', args=('key', )) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.return_value = b'value' + result = await async_storage.get(mock_conn, command) + assert result == b'value' + + @pytest.mark.asyncio + async def test_get_invalid_method(self, async_storage): + """Test getting data with invalid method.""" + mock_conn = AsyncMock() + command = RedisCommand(method='set', args=('key', 'value')) + + with pytest.raises(ValueError, match="Invalid Redis get method"): + await async_storage.get(mock_conn, command) + + @pytest.mark.asyncio + async def test_get_valid_methods(self, async_storage): + """Test various valid get methods.""" + mock_conn = AsyncMock() + # Only methods containing 'get' are valid + valid_methods = ['get', 'hget', 'hgetall', 'mget'] + + for method in valid_methods: + command = RedisCommand(method=method, args=('key', )) + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.return_value = "value" + result = await async_storage.get(mock_conn, command) + assert result == "value" + + @pytest.mark.asyncio + async def test_query_string_type(self, async_storage): + """Test querying string type data.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['key1', 'key2'], # keys command + b'string', # type command for key1 + b'value1', # get command for key1 + b'string', # type command for key2 + b'value2', # get command for key2 + ] + + results = await async_storage.query(mock_conn, 'test:*', conditions) + + assert len(results) == 2 + assert results[0] == ('key1', 'value1') + assert results[1] == ('key2', 'value2') + + @pytest.mark.asyncio + async def test_query_hash_type(self, async_storage): + """Test querying hash type data.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['hash_key'], # keys command + b'hash', # type command + { + 'field': 'value' + }, # hgetall command + ] + + results = await async_storage.query(mock_conn, 'hash:*', conditions) + + assert len(results) == 1 + assert results[0] == ('hash_key', {'field': 'value'}) + + @pytest.mark.asyncio + async def test_query_list_type(self, async_storage): + """Test querying list type data.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['list_key'], # keys command + b'list', # type command + ['item1', 'item2'], # lrange command + ] + + results = await async_storage.query(mock_conn, 'list:*', conditions) + + assert len(results) == 1 + assert results[0] == ('list_key', ['item1', 'item2']) + + @pytest.mark.asyncio + async def test_query_set_type(self, async_storage): + """Test querying set type data.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['set_key'], # keys command + b'set', # type command + {'member1', 'member2'}, # smembers command + ] + + results = await async_storage.query(mock_conn, 'set:*', conditions) + + assert len(results) == 1 + assert results[0] == ('set_key', {'member1', 'member2'}) + + @pytest.mark.asyncio + async def test_query_zset_type(self, async_storage): + """Test querying sorted set type data.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['zset_key'], # keys command + b'zset', # type command + [('member1', 1.0), ('member2', 2.0)], # zrange command + ] + + results = await async_storage.query(mock_conn, 'zset:*', conditions) + + assert len(results) == 1 + assert results[0] == ('zset_key', [('member1', 1.0), ('member2', 2.0)]) + + @pytest.mark.asyncio + async def test_query_with_limit(self, async_storage): + """Test querying with limit.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=2) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['key1', 'key2', 'key3'], # keys command (3 keys) + b'string', # type for key1 + b'value1', # get for key1 + b'string', # type for key2 + b'value2', # get for key2 + ] + + results = await async_storage.query(mock_conn, 'test:*', conditions) + + # Should only process 2 keys due to limit + assert len(results) == 2 + + @pytest.mark.asyncio + async def test_query_with_error(self, async_storage): + """Test querying with error handling.""" + mock_conn = AsyncMock() + conditions = RedisCondition(limit=-1) + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.side_effect = [ + ['key1', 'key2'], # keys command + Exception("Error"), # type command fails + b'string', # type for key2 + b'value2', # get for key2 + ] + + results = await async_storage.query(mock_conn, 'test:*', conditions) + + # Should skip key1 and continue with key2 + assert len(results) == 1 + assert results[0] == ('key2', 'value2') + + @pytest.mark.asyncio + async def test_commit(self, async_storage): + """Test commit operation (no-op for Redis).""" + mock_conn = AsyncMock() + await async_storage.commit(mock_conn) + # Should not raise any error + + @pytest.mark.asyncio + async def test_refresh(self, async_storage): + """Test refresh operation (no-op for Redis).""" + mock_conn = AsyncMock() + await async_storage.refresh(mock_conn, None) + # Should not raise any error + + @pytest.mark.asyncio + async def test_close_async(self, async_storage): + """Test closing async Redis connection pool.""" + from redis.asyncio import ConnectionPool as AsyncConnectionPool + + mock_pool = AsyncMock(spec=AsyncConnectionPool) + mock_pool.disconnect = AsyncMock() + async_storage._redis_pool = mock_pool + + await async_storage.close() + mock_pool.disconnect.assert_called_once() + + @pytest.mark.asyncio + async def test_close_sync(self, sync_storage): + """Test closing sync Redis connection pool.""" + mock_pool = MagicMock() + mock_pool.disconnect = MagicMock(return_value=None) + sync_storage._redis_pool = mock_pool + + await sync_storage.close() + mock_pool.disconnect.assert_called_once() + + @pytest.mark.asyncio + async def test_close_no_pool(self, async_storage): + """Test closing when no pool exists.""" + async_storage._redis_pool = None + await async_storage.close() + # Should not raise any error + + @pytest.mark.asyncio + async def test_close_with_error(self, async_storage): + """Test closing with error.""" + from redis.asyncio import ConnectionPool as AsyncConnectionPool + + mock_pool = AsyncMock(spec=AsyncConnectionPool) + mock_pool.disconnect = AsyncMock(side_effect=Exception("Disconnect error")) + async_storage._redis_pool = mock_pool + + await async_storage.close() + # Should not raise error, just log + + @pytest.mark.asyncio + async def test_getattr_dynamic_command(self, async_storage): + """Test dynamic command method access.""" + mock_pool = MagicMock() + async_storage._redis_pool = mock_pool + + with patch.object(async_storage, 'create_db_session') as mock_ctx: + mock_conn = AsyncMock() + mock_ctx.return_value.__aenter__.return_value = mock_conn + mock_ctx.return_value.__aexit__.return_value = None + + with patch.object(async_storage, 'execute_command') as mock_execute: + mock_execute.return_value = "result" + + # Test dynamic method call + result = await async_storage.custom_command('arg1', kwarg1='value1') + + assert result == "result" + mock_execute.assert_called_once() + call_args = mock_execute.call_args[0] + command = call_args[1] + assert command.method == 'custom_command' + assert command.args == ('arg1', ) + assert command.kwargs == {'kwarg1': 'value1'} + + @pytest.mark.asyncio + async def test_getattr_no_pool(self, async_storage): + """Test dynamic method call without pool.""" + async_storage._redis_pool = None + + # Should return None when no pool + result = await async_storage.some_command('arg') + assert result is None + + @pytest.mark.asyncio + async def test_execute_command_with_method(self, async_storage): + """Test executing command with existing method.""" + mock_conn = AsyncMock() + mock_conn.set = AsyncMock(return_value="OK") + + command = RedisCommand(method='set', args=('key', 'value'), expire=RedisExpire(ttl=Ttl(enable=False))) + + result = await async_storage.execute_command(mock_conn, command) + + assert result == "OK" + mock_conn.set.assert_called_once_with('key', 'value') + + @pytest.mark.asyncio + async def test_execute_command_without_method(self, async_storage): + """Test executing command without existing method.""" + mock_conn = AsyncMock() + mock_conn.execute_command = AsyncMock(return_value="OK") + + command = RedisCommand(method='custom', args=('key', 'value'), expire=RedisExpire(ttl=Ttl(enable=False))) + + with patch.object(mock_conn, 'custom', None): + result = await async_storage.execute_command(mock_conn, command) + + assert result == "OK" + mock_conn.execute_command.assert_called_once_with('CUSTOM', 'key', 'value') + + @pytest.mark.asyncio + async def test_execute_command_with_expire(self, async_storage): + """Test executing command with TTL expiration.""" + mock_conn = AsyncMock() + mock_conn.set = AsyncMock(return_value="OK") + + command = RedisCommand(method='set', + args=('key', 'value'), + expire=RedisExpire(ttl=Ttl(enable=True, ttl_seconds=60))) + + with patch.object(async_storage, 'expire') as mock_expire: + mock_expire.return_value = None + result = await async_storage.execute_command(mock_conn, command) + + assert result == "OK" + mock_expire.assert_called_once() + # Check that expire was called with correct key + expire_command = mock_expire.call_args[0][1] + assert expire_command.key == 'key' + + @pytest.mark.asyncio + async def test_execute_command_sync(self, sync_storage): + """Test executing command with sync connection.""" + mock_conn = MagicMock() + mock_conn.set = MagicMock(return_value="OK") + + command = RedisCommand(method='set', args=('key', 'value'), expire=RedisExpire(ttl=Ttl(enable=False))) + + result = await sync_storage.execute_command(mock_conn, command) + + assert result == "OK" + mock_conn.set.assert_called_once_with('key', 'value') + + @pytest.mark.asyncio + async def test_expire_disabled(self, async_storage): + """Test expire when TTL is disabled.""" + mock_conn = AsyncMock() + expire_command = RedisExpire(ttl=Ttl(enable=False)) + + result = await async_storage.expire(mock_conn, expire_command) + assert result is None + + @pytest.mark.asyncio + async def test_expire_with_args(self, async_storage): + """Test expire with explicit args.""" + mock_conn = AsyncMock() + mock_conn.expire = AsyncMock(return_value=1) + + expire_command = RedisExpire(key='test_key', + method='expire', + ttl=Ttl(enable=True, ttl_seconds=60), + args=('test_key', 60)) + + result = await async_storage.expire(mock_conn, expire_command) + + assert result == 1 + mock_conn.expire.assert_called_once_with('test_key', 60) + + @pytest.mark.asyncio + async def test_expire_without_args(self, async_storage): + """Test expire without explicit args.""" + mock_conn = AsyncMock() + mock_conn.expire = AsyncMock(return_value=1) + + expire_command = RedisExpire(key='test_key', method='expire', ttl=Ttl(enable=True, ttl_seconds=120)) + + result = await async_storage.expire(mock_conn, expire_command) + + assert result == 1 + mock_conn.expire.assert_called_once_with('test_key', 120) + + @pytest.mark.asyncio + async def test_expire_without_method(self, async_storage): + """Test expire using execute_command.""" + mock_conn = AsyncMock() + mock_conn.execute_command = AsyncMock(return_value=1) + + expire_command = RedisExpire( + key='test_key', + method='pexpire', # Method that doesn't exist + ttl=Ttl(enable=True, ttl_seconds=60)) + + with patch.object(mock_conn, 'pexpire', None): + result = await async_storage.expire(mock_conn, expire_command) + + assert result == 1 + mock_conn.execute_command.assert_called_once_with('PEXPIRE', 'test_key', 60) + + @pytest.mark.asyncio + async def test_expire_sync_connection(self, sync_storage): + """Test expire with sync connection.""" + mock_conn = MagicMock() + mock_conn.expire = MagicMock(return_value=1) + + expire_command = RedisExpire(key='test_key', method='expire', ttl=Ttl(enable=True, ttl_seconds=60)) + + result = await sync_storage.expire(mock_conn, expire_command) + + assert result == 1 + mock_conn.expire.assert_called_once_with('test_key', 60) + + +class TestRedisAsyncContextManager: + """Test suite for RedisAsyncContextManager class.""" + + @pytest.mark.asyncio + async def test_context_manager_async(self): + """Test async context manager with async Redis.""" + from redis.asyncio import Redis as AsyncRedis + + mock_storage = MagicMock() + mock_session = AsyncMock(spec=AsyncRedis) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock() + + mock_storage.create_redis_session = AsyncMock(return_value=mock_session) + + ctx = RedisAsyncContextManager(mock_storage) + + async with ctx as session: + assert session is mock_session + mock_session.__aenter__.assert_called_once() + + mock_session.__aexit__.assert_called_once() + + @pytest.mark.asyncio + async def test_context_manager_sync(self): + """Test async context manager with sync Redis.""" + mock_storage = MagicMock() + mock_session = MagicMock() + mock_session.__enter__ = MagicMock(return_value=mock_session) + mock_session.__exit__ = MagicMock() + + mock_storage.create_redis_session = AsyncMock(return_value=mock_session) + + ctx = RedisAsyncContextManager(mock_storage) + + async with ctx as session: + assert session is mock_session + mock_session.__enter__.assert_called_once() + + mock_session.__exit__.assert_called_once() + + @pytest.mark.asyncio + async def test_context_manager_no_session(self): + """Test context manager exit without session.""" + mock_storage = MagicMock() + mock_storage.create_redis_session = AsyncMock(return_value=None) + + ctx = RedisAsyncContextManager(mock_storage) + ctx._session = None + + # Should not raise error + await ctx.__aexit__(None, None, None) + + +class TestRedisDataClasses: + """Test suite for Redis data classes.""" + + def test_redis_expire_default(self): + """Test RedisExpire with default values.""" + expire = RedisExpire() + + assert expire.key == "" + assert expire.method == "expire" + assert isinstance(expire.ttl, Ttl) + assert expire.args == () + assert expire.kwargs == {} + + def test_redis_expire_custom(self): + """Test RedisExpire with custom values.""" + ttl = Ttl(enable=True, ttl_seconds=120) + expire = RedisExpire(key="test_key", method="pexpire", ttl=ttl, args=("arg1", ), kwargs={"kw1": "val1"}) + + assert expire.key == "test_key" + assert expire.method == "pexpire" + assert expire.ttl.ttl_seconds == 120 + assert expire.args == ("arg1", ) + assert expire.kwargs == {"kw1": "val1"} + + def test_redis_command_default(self): + """Test RedisCommand with default values.""" + command = RedisCommand(method="set") + + assert command.method == "set" + assert isinstance(command.expire, RedisExpire) + assert command.args == () + assert command.kwargs == {} + + def test_redis_command_custom(self): + """Test RedisCommand with custom values.""" + expire = RedisExpire(key="test_key", ttl=Ttl(enable=True, ttl_seconds=60)) + command = RedisCommand(method="hset", expire=expire, args=("hash_key", "field", "value"), kwargs={"nx": True}) + + assert command.method == "hset" + assert command.expire.key == "test_key" + assert command.args == ("hash_key", "field", "value") + assert command.kwargs == {"nx": True} + + def test_redis_condition_default(self): + """Test RedisCondition with default values.""" + condition = RedisCondition() + + assert condition.limit == -1 + + def test_redis_condition_custom(self): + """Test RedisCondition with custom limit.""" + condition = RedisCondition(limit=100) + + assert condition.limit == 100 + + +class TestExpireMethods: + """Test suite for EXPIRE_METHOD constant.""" + + def test_expire_method_list(self): + """Test EXPIRE_METHOD contains expected methods.""" + expected_methods = ['set', 'hset', 'hmset', 'lpush', 'rpush', 'sadd', 'zadd'] + + assert EXPIRE_METHOD == expected_methods + + def test_expire_method_immutable(self): + """Test EXPIRE_METHOD list contents.""" + # Verify all expected methods are present + for method in ['set', 'hset', 'hmset', 'lpush', 'rpush', 'sadd', 'zadd']: + assert method in EXPIRE_METHOD + + +class TestRedisStorageIntegration: + """Integration tests for RedisStorage.""" + + @pytest.mark.asyncio + async def test_full_workflow_async(self): + """Test complete workflow with async storage.""" + from redis.asyncio import ConnectionPool as AsyncConnectionPool + from redis.asyncio import Redis as AsyncRedis + + storage = RedisStorage(redis_url="redis://localhost:6379/0", is_async=True) + + # Create pool instance without calling __init__ to avoid real connections + mock_pool = AsyncConnectionPool.__new__(AsyncConnectionPool) + mock_pool.disconnect = AsyncMock() + storage._redis_pool = mock_pool + + # Create connection instance without calling __init__ + mock_conn = AsyncRedis.__new__(AsyncRedis) + mock_conn.set = AsyncMock(return_value="OK") + mock_conn.get = AsyncMock(return_value=b"value") + mock_conn.delete = AsyncMock(return_value=1) + mock_conn.expire = AsyncMock(return_value=1) + + # Add data with TTL + command = RedisCommand(method='set', + args=('key', 'value'), + expire=RedisExpire(ttl=Ttl(enable=True, ttl_seconds=60))) + await storage.add(mock_conn, command) + mock_conn.set.assert_called_once_with('key', 'value') + mock_conn.expire.assert_called_once() + + # Get data + get_command = RedisCommand(method='get', args=('key', )) + result = await storage.get(mock_conn, get_command) + assert result == b"value" + mock_conn.get.assert_called_once_with('key') + + # Delete data + await storage.delete(mock_conn, 'key') + mock_conn.delete.assert_called_once_with('key') + + # Close + await storage.close() + mock_pool.disconnect.assert_called_once() + + @pytest.mark.asyncio + async def test_context_manager_workflow(self): + """Test workflow using context manager.""" + from redis.asyncio import ConnectionPool as AsyncConnectionPool + from redis.asyncio import Redis as AsyncRedis + + storage = RedisStorage(redis_url="redis://localhost:6379/0", is_async=True) + + # Create pool instance without calling __init__ + mock_pool = AsyncConnectionPool.__new__(AsyncConnectionPool) + mock_pool.disconnect = AsyncMock() + storage._redis_pool = mock_pool + + # Create connection instance without calling __init__ + mock_conn = AsyncRedis.__new__(AsyncRedis) + mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) + mock_conn.__aexit__ = AsyncMock() + mock_conn.set = AsyncMock(return_value="OK") + + # Patch create_redis_session to return the mock connection instance + with patch.object(storage, 'create_redis_session', return_value=mock_conn): + async with storage.create_db_session() as conn: + assert conn is mock_conn + + command = RedisCommand(method='set', args=('key', 'value'), expire=RedisExpire(ttl=Ttl(enable=False))) + await storage.execute_command(conn, command) + + # Verify set was called + mock_conn.set.assert_called_once_with('key', 'value') + + # Verify context manager methods were called + mock_conn.__aenter__.assert_called() + mock_conn.__aexit__.assert_called() diff --git a/tests/storage/test_sql.py b/tests/storage/test_sql.py new file mode 100644 index 000000000..b1a3ce2ab --- /dev/null +++ b/tests/storage/test_sql.py @@ -0,0 +1,520 @@ +# 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. +"""Tests for SQL storage implementation.""" +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from sqlalchemy import Integer +from sqlalchemy import String +from sqlalchemy.exc import ArgumentError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import Session as SyncSession +from sqlalchemy.orm import mapped_column +from trpc_agent_sdk.storage import SqlAsyncContextManager +from trpc_agent_sdk.storage import SqlCondition +from trpc_agent_sdk.storage import SqlKey +from trpc_agent_sdk.storage import SqlStorage +from trpc_agent_sdk.storage import StorageData + + +# Model class for testing SQL storage operations +class SampleModel(StorageData): + """Sample model for SQL storage tests.""" + __tablename__ = "test_table" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + value: Mapped[int] = mapped_column(Integer) + + +class TestSqlStorage: + """Test suite for SqlStorage class.""" + + @pytest.fixture + def db_url(self): + """Database URL fixture.""" + return "sqlite:///:memory:" + + @pytest.fixture + def async_db_url(self): + """Async database URL fixture.""" + pytest.importorskip("aiosqlite") + pytest.importorskip("greenlet") + return "sqlite+aiosqlite:///:memory:" + + @pytest.fixture + async def sync_storage(self, db_url): + """Synchronous SQL storage fixture with initialized engine.""" + storage = SqlStorage(is_async=False, db_url=db_url, metadata=StorageData.metadata) + await storage.create_sql_engine() + yield storage + await storage.close() + + @pytest.fixture + async def async_storage(self, async_db_url): + """Asynchronous SQL storage fixture with initialized engine.""" + storage = SqlStorage(is_async=True, db_url=async_db_url, metadata=StorageData.metadata) + await storage.create_sql_engine() + yield storage + await storage.close() + + # Test SqlStorage initialization + + def test_init(self, db_url): + """Test SqlStorage initialization.""" + storage = SqlStorage(is_async=False, + db_url=db_url, + metadata=StorageData.metadata, + pool_pre_ping=True, + pool_recycle=3600) + + assert storage._db_engine is None + assert storage._database_session_factory is None + assert storage.inspector is None + + def test_init_with_default_metadata(self, db_url): + """Test SqlStorage initialization with default metadata.""" + storage = SqlStorage(is_async=False, db_url=db_url) + assert storage._db_engine is None + + # Test create_sql_engine + + @pytest.mark.asyncio + async def test_create_sql_engine_async(self, async_db_url): + """Test creating async SQL engine.""" + storage = SqlStorage(is_async=True, db_url=async_db_url, metadata=StorageData.metadata) + + await storage.create_sql_engine() + + assert storage._db_engine is not None + assert storage._database_session_factory is not None + assert storage.inspector is not None + + await storage.close() + + @pytest.mark.asyncio + async def test_create_sql_engine_sync(self, db_url): + """Test creating sync SQL engine.""" + storage = SqlStorage(is_async=False, db_url=db_url, metadata=StorageData.metadata) + + await storage.create_sql_engine() + + assert storage._db_engine is not None + assert storage._database_session_factory is not None + assert storage.inspector is not None + + await storage.close() + + @pytest.mark.asyncio + async def test_create_sql_engine_already_exists(self, async_storage): + """Test creating SQL engine when engine already exists.""" + initial_engine = async_storage._db_engine + + # Should not create new engine + await async_storage.create_sql_engine() + + assert async_storage._db_engine is initial_engine + + @pytest.mark.asyncio + async def test_create_sql_engine_sqlite_pragma(self): + """Test SQLite pragma is set when creating engine.""" + storage = SqlStorage(is_async=False, db_url="sqlite:///:memory:", metadata=StorageData.metadata) + + with patch('trpc_agent_sdk.storage._sql.event.listen') as mock_event_listen: + with patch('trpc_agent_sdk.storage._sql.create_engine') as mock_create_engine: + mock_engine = MagicMock() + mock_engine.dialect.name = "sqlite" + mock_create_engine.return_value = mock_engine + + with patch('trpc_agent_sdk.storage._sql.inspect') as mock_inspect: + mock_inspect.return_value = MagicMock() + + with patch('trpc_agent_sdk.storage._sql.sessionmaker') as mock_sessionmaker: + mock_sessionmaker.return_value = MagicMock() + + await storage.create_sql_engine() + + # Should listen for connect event + mock_event_listen.assert_called_once() + + @pytest.mark.asyncio + async def test_create_sql_engine_argument_error(self): + """Test error handling for ArgumentError.""" + storage = SqlStorage(is_async=True, db_url="invalid://url", metadata=StorageData.metadata) + + with patch('trpc_agent_sdk.storage._sql.create_async_engine') as mock_create_engine: + mock_create_engine.side_effect = ArgumentError("Invalid URL", "url", None) + + with pytest.raises(ValueError, match="Invalid database URL format"): + await storage.create_sql_engine() + + @pytest.mark.asyncio + async def test_create_sql_engine_import_error(self): + """Test error handling for ImportError.""" + storage = SqlStorage(is_async=True, db_url="sqlite+aiosqlite:///:memory:", metadata=StorageData.metadata) + + with patch('trpc_agent_sdk.storage._sql.create_async_engine') as mock_create_engine: + mock_create_engine.side_effect = ImportError("Module not found") + + with pytest.raises(ValueError, match="Database related module not found"): + await storage.create_sql_engine() + + @pytest.mark.asyncio + async def test_create_sql_engine_generic_error(self): + """Test error handling for generic exception.""" + storage = SqlStorage(is_async=True, db_url="sqlite+aiosqlite:///:memory:", metadata=StorageData.metadata) + + with patch('trpc_agent_sdk.storage._sql.create_async_engine') as mock_create_engine: + mock_create_engine.side_effect = Exception("Connection error") + + with pytest.raises(ValueError, match="Failed to create database engine"): + await storage.create_sql_engine() + + # Test create_sql_session + + @pytest.mark.asyncio + async def test_create_sql_session_async(self, async_storage): + """Test creating async SQL session.""" + session = await async_storage.create_sql_session() + + assert session is not None + assert isinstance(session, AsyncSession) + + @pytest.mark.asyncio + async def test_create_sql_session_sync(self, sync_storage): + """Test creating sync SQL session.""" + session = await sync_storage.create_sql_session() + + assert session is not None + assert isinstance(session, SyncSession) + + @pytest.mark.asyncio + async def test_create_sql_session_no_factory(self): + """Test creating session without factory raises error.""" + storage = SqlStorage(is_async=True, db_url="sqlite+aiosqlite:///:memory:", metadata=StorageData.metadata) + # Don't initialize engine, just set factory to None to test error handling + storage._database_session_factory = None + + # Mock create_sql_engine to do nothing + with patch.object(storage, 'create_sql_engine'): + with pytest.raises(ValueError, match="Database session factory not initialized"): + await storage.create_sql_session() + + # Test create_db_session + + def test_create_db_session(self, db_url): + """Test creating database session context manager.""" + storage = SqlStorage(is_async=False, db_url=db_url, metadata=StorageData.metadata) + ctx = storage.create_db_session() + + assert isinstance(ctx, SqlAsyncContextManager) + + @pytest.mark.asyncio + async def test_context_manager_async(self, async_storage): + """Test SqlAsyncContextManager with async session.""" + async with async_storage.create_db_session() as session: + assert session is not None + assert isinstance(session, AsyncSession) + + @pytest.mark.asyncio + async def test_context_manager_sync(self, sync_storage): + """Test SqlAsyncContextManager with sync session.""" + async with sync_storage.create_db_session() as session: + assert session is not None + assert isinstance(session, SyncSession) + + # Test CRUD operations + + @pytest.mark.asyncio + async def test_add_async(self, async_storage): + """Test adding data with async session.""" + async with async_storage.create_db_session() as session: + test_data = SampleModel(id=1, name="test", value=42) + result = await async_storage.add(session, test_data) + await async_storage.commit(session) + + assert result is None # add returns None + + @pytest.mark.asyncio + async def test_add_sync(self, sync_storage): + """Test adding data with sync session.""" + async with sync_storage.create_db_session() as session: + test_data = SampleModel(id=2, name="test", value=42) + result = await sync_storage.add(session, test_data) + await sync_storage.commit(session) + + assert result is None # add returns None + + @pytest.mark.asyncio + async def test_get_async(self, async_storage): + """Test getting data with async session.""" + async with async_storage.create_db_session() as session: + # Add test data first + test_data = SampleModel(id=3, name="test", value=42) + await async_storage.add(session, test_data) + await async_storage.commit(session) + + # Get the data + sql_key = SqlKey(key=(3, ), storage_cls=SampleModel) + result = await async_storage.get(session, sql_key) + + assert result is not None + assert result.name == "test" + assert result.value == 42 + + @pytest.mark.asyncio + async def test_get_sync(self, sync_storage): + """Test getting data with sync session.""" + async with sync_storage.create_db_session() as session: + # Add test data first + test_data = SampleModel(id=4, name="test", value=42) + await sync_storage.add(session, test_data) + await sync_storage.commit(session) + + # Get the data + sql_key = SqlKey(key=(4, ), storage_cls=SampleModel) + result = await sync_storage.get(session, sql_key) + + assert result is not None + assert result.name == "test" + assert result.value == 42 + + @pytest.mark.asyncio + async def test_get_not_found(self, async_storage): + """Test getting data that doesn't exist.""" + async with async_storage.create_db_session() as session: + sql_key = SqlKey(key=(999, ), storage_cls=SampleModel) + result = await async_storage.get(session, sql_key) + + assert result is None + + @pytest.mark.asyncio + async def test_query_async(self, async_storage): + """Test querying data with async session.""" + async with async_storage.create_db_session() as session: + # Add test data + test_data1 = SampleModel(id=5, name="test1", value=10) + test_data2 = SampleModel(id=6, name="test2", value=20) + await async_storage.add(session, test_data1) + await async_storage.add(session, test_data2) + await async_storage.commit(session) + + # Query the data + sql_key = SqlKey(key=tuple(), storage_cls=SampleModel) + conditions = SqlCondition(filters=None, limit=None) + result = await async_storage.query(session, sql_key, conditions) + + assert len(result) >= 2 + + @pytest.mark.asyncio + async def test_query_sync(self, sync_storage): + """Test querying data with sync session.""" + async with sync_storage.create_db_session() as session: + # Add test data + test_data1 = SampleModel(id=7, name="test1", value=10) + test_data2 = SampleModel(id=8, name="test2", value=20) + await sync_storage.add(session, test_data1) + await sync_storage.add(session, test_data2) + await sync_storage.commit(session) + + # Query the data + sql_key = SqlKey(key=tuple(), storage_cls=SampleModel) + conditions = SqlCondition(filters=None, limit=None) + result = await sync_storage.query(session, sql_key, conditions) + + assert len(result) >= 2 + + @pytest.mark.asyncio + async def test_query_with_filters(self, async_storage): + """Test querying data with filters.""" + async with async_storage.create_db_session() as session: + # Add test data + test_data1 = SampleModel(id=9, name="match", value=10) + test_data2 = SampleModel(id=10, name="nomatch", value=20) + await async_storage.add(session, test_data1) + await async_storage.add(session, test_data2) + await async_storage.commit(session) + + # Query with filter + sql_key = SqlKey(key=tuple(), storage_cls=SampleModel) + filters = [SampleModel.name == "match"] + conditions = SqlCondition(filters=filters, limit=None) + result = await async_storage.query(session, sql_key, conditions) + + assert len(result) >= 1 + assert all(item.name == "match" for item in result) + + @pytest.mark.asyncio + async def test_query_with_limit(self, async_storage): + """Test querying data with limit.""" + async with async_storage.create_db_session() as session: + # Add test data + for i in range(11, 16): + test_data = SampleModel(id=i, name=f"test{i}", value=i * 10) + await async_storage.add(session, test_data) + await async_storage.commit(session) + + # Query with limit + sql_key = SqlKey(key=tuple(), storage_cls=SampleModel) + conditions = SqlCondition(filters=None, limit=3) + result = await async_storage.query(session, sql_key, conditions) + + # May have more than 3 due to previous tests, but limit should work + # Just check that query executes successfully + assert isinstance(result, list) + + @pytest.mark.asyncio + async def test_delete_async(self, async_storage): + """Test deleting data with async session.""" + async with async_storage.create_db_session() as session: + # Add test data + test_data = SampleModel(id=16, name="todelete", value=100) + await async_storage.add(session, test_data) + await async_storage.commit(session) + + # Delete the data + sql_key = SqlKey(key=(16, ), storage_cls=SampleModel) + conditions = SqlCondition(filters=None) + await async_storage.delete(session, sql_key, conditions) + await async_storage.commit(session) + + # Verify deletion + result = await async_storage.get(session, sql_key) + assert result is None + + @pytest.mark.asyncio + async def test_delete_sync(self, sync_storage): + """Test deleting data with sync session.""" + async with sync_storage.create_db_session() as session: + # Add test data + test_data = SampleModel(id=17, name="todelete", value=100) + await sync_storage.add(session, test_data) + await sync_storage.commit(session) + + # Delete the data + sql_key = SqlKey(key=(17, ), storage_cls=SampleModel) + conditions = SqlCondition(filters=None) + await sync_storage.delete(session, sql_key, conditions) + await sync_storage.commit(session) + + # Verify deletion + result = await sync_storage.get(session, sql_key) + assert result is None + + # Test commit and refresh + + @pytest.mark.asyncio + async def test_commit_async(self, async_storage): + """Test committing changes with async session.""" + async with async_storage.create_db_session() as session: + test_data = SampleModel(id=18, name="test", value=42) + await async_storage.add(session, test_data) + await async_storage.commit(session) + + # Verify data is committed + sql_key = SqlKey(key=(18, ), storage_cls=SampleModel) + result = await async_storage.get(session, sql_key) + assert result is not None + + @pytest.mark.asyncio + async def test_commit_sync(self, sync_storage): + """Test committing changes with sync session.""" + async with sync_storage.create_db_session() as session: + test_data = SampleModel(id=19, name="test", value=42) + await sync_storage.add(session, test_data) + await sync_storage.commit(session) + + # Verify data is committed + sql_key = SqlKey(key=(19, ), storage_cls=SampleModel) + result = await sync_storage.get(session, sql_key) + assert result is not None + + @pytest.mark.asyncio + async def test_refresh_async(self, async_storage): + """Test refreshing data with async session.""" + async with async_storage.create_db_session() as session: + test_data = SampleModel(id=20, name="test", value=42) + await async_storage.add(session, test_data) + await async_storage.commit(session) + + # Refresh the data + await async_storage.refresh(session, test_data) + assert test_data.id == 20 + + @pytest.mark.asyncio + async def test_refresh_sync(self, sync_storage): + """Test refreshing data with sync session.""" + async with sync_storage.create_db_session() as session: + test_data = SampleModel(id=21, name="test", value=42) + await sync_storage.add(session, test_data) + await sync_storage.commit(session) + + # Refresh the data + await sync_storage.refresh(session, test_data) + assert test_data.id == 21 + + # Test close + + @pytest.mark.asyncio + async def test_close_async(self, async_db_url): + """Test closing async SQL engine.""" + storage = SqlStorage(is_async=True, db_url=async_db_url, metadata=StorageData.metadata) + + await storage.create_sql_engine() + + assert storage._db_engine is not None + + await storage.close() + # Engine should be disposed (no easy way to check, but should not raise) + + @pytest.mark.asyncio + async def test_close_sync(self, db_url): + """Test closing sync SQL engine.""" + storage = SqlStorage(is_async=False, db_url=db_url, metadata=StorageData.metadata) + + await storage.create_sql_engine() + + assert storage._db_engine is not None + + await storage.close() + # Engine should be disposed (no easy way to check, but should not raise) + + @pytest.mark.asyncio + async def test_close_no_engine(self): + """Test closing when no engine exists.""" + storage = SqlStorage(is_async=False, db_url="sqlite:///:memory:", metadata=StorageData.metadata) + await storage.close() + # Should not raise any error + + # Test dataclasses + + def test_sql_key_dataclass(self): + """Test SqlKey dataclass.""" + sql_key = SqlKey(key=(1, 2, 3), storage_cls=SampleModel) + + assert sql_key.key == (1, 2, 3) + assert sql_key.storage_cls == SampleModel + + def test_sql_condition_dataclass(self): + """Test SqlCondition dataclass.""" + filters = [SampleModel.name == "test"] + order_func = lambda: SampleModel.id.desc() + + condition = SqlCondition(filters=filters, order_func=order_func, limit=10) + + assert condition.filters == filters + assert condition.order_func == order_func + assert condition.limit == 10 + + def test_sql_condition_defaults(self): + """Test SqlCondition with default values.""" + condition = SqlCondition() + + assert condition.filters is None + assert condition.order_func is None + assert condition.limit is None diff --git a/tests/storage/test_sql_common.py b/tests/storage/test_sql_common.py new file mode 100644 index 000000000..bfd83d194 --- /dev/null +++ b/tests/storage/test_sql_common.py @@ -0,0 +1,609 @@ +# 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. +"""Tests for SQL common utilities.""" + +from __future__ import annotations + +import base64 +import json +import pickle +from copy import deepcopy +from types import SimpleNamespace +from typing import Iterator +from unittest.mock import MagicMock + +import pytest + +from sqlalchemy import Text +from sqlalchemy.dialects import mysql +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.types import DateTime +from sqlalchemy.types import PickleType +from sqlalchemy.types import String + +from trpc_agent_sdk.storage._sql_common import ( + DynamicJSON, + DynamicJSONOptions, + DynamicPickleType, + PreciseTimestamp, + SpannerPickleType, + StorageData, + UTF8MB4String, + decode_content, + decode_grounding_metadata, + decode_grounding_metadata, + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY, + TypeDecoratorHookRegistry, +) + +# --------------------------------------------------------------------------- +# decode_content +# --------------------------------------------------------------------------- + + +class TestDecodeContent: + + def test_none_input(self): + assert decode_content(None) is None + + def test_empty_dict(self): + assert decode_content({}) is None + + def test_valid_content_dict(self): + content_dict = {"role": "user", "parts": [{"text": "hello"}]} + result = decode_content(content_dict) + assert result is not None + assert result.role == "user" + + def test_content_with_model_role(self): + content_dict = {"role": "model", "parts": [{"text": "response"}]} + result = decode_content(content_dict) + assert result.role == "model" + + +# --------------------------------------------------------------------------- +# decode_grounding_metadata +# --------------------------------------------------------------------------- + + +class TestDecodeGroundingMetadata: + + def test_none_input(self): + assert decode_grounding_metadata(None) is None + + def test_empty_dict(self): + assert decode_grounding_metadata({}) is None + + def test_valid_grounding_metadata(self): + metadata_dict = {"search_entry_point": {"rendered_content": "test"}} + result = decode_grounding_metadata(metadata_dict) + assert result is not None + + +# --------------------------------------------------------------------------- +# DynamicJSONOptions +# --------------------------------------------------------------------------- + + +class TestDynamicJSONOptions: + + def setup_method(self): + DynamicJSONOptions._json_dumps_kwargs = {} + DynamicJSONOptions._json_loads_kwargs = {} + + def test_default_dumps_kwargs_empty(self): + assert DynamicJSONOptions.get_json_dumps_kwargs() == {} + + def test_default_loads_kwargs_empty(self): + assert DynamicJSONOptions.get_json_loads_kwargs() == {} + + def test_set_dumps_kwargs(self): + DynamicJSONOptions.set_json_dumps_kwargs({"ensure_ascii": False}) + result = DynamicJSONOptions.get_json_dumps_kwargs() + assert result == {"ensure_ascii": False} + + def test_set_loads_kwargs(self): + DynamicJSONOptions.set_json_loads_kwargs({"strict": False}) + result = DynamicJSONOptions.get_json_loads_kwargs() + assert result == {"strict": False} + + def test_set_dumps_kwargs_updates(self): + DynamicJSONOptions.set_json_dumps_kwargs({"ensure_ascii": False}) + DynamicJSONOptions.set_json_dumps_kwargs({"indent": 2}) + result = DynamicJSONOptions.get_json_dumps_kwargs() + assert result == {"ensure_ascii": False, "indent": 2} + + def test_set_loads_kwargs_updates(self): + DynamicJSONOptions.set_json_loads_kwargs({"strict": False}) + DynamicJSONOptions.set_json_loads_kwargs({"encoding": "utf-8"}) + result = DynamicJSONOptions.get_json_loads_kwargs() + assert result == {"strict": False, "encoding": "utf-8"} + + def test_set_dumps_kwargs_overwrites_existing_key(self): + DynamicJSONOptions.set_json_dumps_kwargs({"ensure_ascii": False}) + DynamicJSONOptions.set_json_dumps_kwargs({"ensure_ascii": True}) + result = DynamicJSONOptions.get_json_dumps_kwargs() + assert result["ensure_ascii"] is True + + +# --------------------------------------------------------------------------- +# DynamicJSON TypeDecorator +# --------------------------------------------------------------------------- + + +def _make_dialect(name: str) -> MagicMock: + """Helper to create a mock SQLAlchemy dialect.""" + dialect = MagicMock() + dialect.name = name + if name == "postgresql": + dialect.type_descriptor = lambda t: t + elif name == "mysql": + dialect.type_descriptor = lambda t: t + else: + dialect.type_descriptor = lambda t: t + return dialect + + +class TestDynamicJSON: + + def test_impl_is_text(self): + assert DynamicJSON.impl is Text + + def test_load_dialect_impl_postgresql(self): + dj = DynamicJSON() + dialect = _make_dialect("postgresql") + result = dj.load_dialect_impl(dialect) + assert result is postgresql.JSONB or isinstance(result, postgresql.JSONB) + + def test_load_dialect_impl_mysql(self): + dj = DynamicJSON() + dialect = _make_dialect("mysql") + result = dj.load_dialect_impl(dialect) + assert result is mysql.LONGTEXT or isinstance(result, mysql.LONGTEXT) + + def test_load_dialect_impl_sqlite(self): + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + result = dj.load_dialect_impl(dialect) + assert result is Text or isinstance(result, Text) + + def test_process_bind_param_postgresql_returns_dict(self): + dj = DynamicJSON() + dialect = _make_dialect("postgresql") + value = {"key": "value"} + result = dj.process_bind_param(value, dialect) + assert result == {"key": "value"} + + def test_process_bind_param_sqlite_returns_json_string(self): + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + value = {"key": "value", "num": 42} + result = dj.process_bind_param(value, dialect) + assert isinstance(result, str) + assert json.loads(result) == value + + def test_process_bind_param_mysql_returns_json_string(self): + dj = DynamicJSON() + dialect = _make_dialect("mysql") + value = {"items": [1, 2, 3]} + result = dj.process_bind_param(value, dialect) + assert isinstance(result, str) + assert json.loads(result) == value + + def test_process_bind_param_none(self): + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + assert dj.process_bind_param(None, dialect) is None + + def test_process_result_value_postgresql_returns_dict(self): + dj = DynamicJSON() + dialect = _make_dialect("postgresql") + value = {"key": "value"} + result = dj.process_result_value(value, dialect) + assert result == {"key": "value"} + + def test_process_result_value_sqlite_parses_json(self): + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + value = '{"key": "value", "num": 42}' + result = dj.process_result_value(value, dialect) + assert result == {"key": "value", "num": 42} + + def test_process_result_value_none(self): + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + assert dj.process_result_value(None, dialect) is None + + def test_process_bind_param_respects_dumps_kwargs(self): + DynamicJSONOptions._json_dumps_kwargs = {} + DynamicJSONOptions.set_json_dumps_kwargs({"ensure_ascii": False}) + + dj = DynamicJSON() + dialect = _make_dialect("sqlite") + value = {"text": "你好"} + result = dj.process_bind_param(value, dialect) + assert "你好" in result + + DynamicJSONOptions._json_dumps_kwargs = {} + + +# --------------------------------------------------------------------------- +# UTF8MB4String TypeDecorator +# --------------------------------------------------------------------------- + + +class TestUTF8MB4String: + + def test_impl_is_string(self): + assert UTF8MB4String.impl is String + + def test_cache_ok(self): + assert UTF8MB4String.cache_ok is True + + def test_init_with_length(self): + s = UTF8MB4String(length=255) + assert s.length == 255 + + def test_init_without_length(self): + s = UTF8MB4String() + assert s.length is None + + def test_load_dialect_impl_mysql(self): + s = UTF8MB4String(length=128) + dialect = _make_dialect("mysql") + result = s.load_dialect_impl(dialect) + assert isinstance(result, mysql.VARCHAR) + + def test_load_dialect_impl_sqlite_with_length(self): + s = UTF8MB4String(length=128) + dialect = _make_dialect("sqlite") + result = s.load_dialect_impl(dialect) + assert isinstance(result, String) + + def test_load_dialect_impl_sqlite_without_length(self): + s = UTF8MB4String() + dialect = _make_dialect("sqlite") + result = s.load_dialect_impl(dialect) + assert isinstance(result, String) + + def test_load_dialect_impl_postgresql(self): + s = UTF8MB4String(length=256) + dialect = _make_dialect("postgresql") + result = s.load_dialect_impl(dialect) + assert isinstance(result, String) + + +# --------------------------------------------------------------------------- +# PreciseTimestamp TypeDecorator +# --------------------------------------------------------------------------- + + +class TestPreciseTimestamp: + + def test_impl_is_datetime(self): + assert PreciseTimestamp.impl is DateTime + + def test_cache_ok(self): + assert PreciseTimestamp.cache_ok is True + + def test_load_dialect_impl_mysql(self): + pt = PreciseTimestamp() + dialect = _make_dialect("mysql") + result = pt.load_dialect_impl(dialect) + assert isinstance(result, mysql.DATETIME) + + def test_load_dialect_impl_sqlite(self): + pt = PreciseTimestamp() + dialect = _make_dialect("sqlite") + result = pt.load_dialect_impl(dialect) + assert result is DateTime or isinstance(result, DateTime) + + +# --------------------------------------------------------------------------- +# DynamicPickleType TypeDecorator +# --------------------------------------------------------------------------- + + +class TestDynamicPickleType: + + def test_impl_is_pickle_type(self): + assert DynamicPickleType.impl is PickleType + + def test_load_dialect_impl_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("spanner+spanner") + result = dpt.load_dialect_impl(dialect) + assert result is SpannerPickleType + + def test_load_dialect_impl_non_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("sqlite") + result = dpt.load_dialect_impl(dialect) + assert result is PickleType or isinstance(result, PickleType) + + def test_process_bind_param_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("spanner+spanner") + value = {"key": "value", "nums": [1, 2, 3]} + result = dpt.process_bind_param(value, dialect) + assert pickle.loads(result) == value + + def test_process_bind_param_mysql(self): + dpt = DynamicPickleType() + dialect = _make_dialect("mysql") + value = {"key": "value", "nums": [1, 2, 3]} + result = dpt.process_bind_param(value, dialect) + assert isinstance(result, bytes) + assert pickle.loads(result) == value + + def test_process_bind_param_non_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("sqlite") + value = {"key": "value"} + result = dpt.process_bind_param(value, dialect) + assert result == value + + def test_process_bind_param_none(self): + dpt = DynamicPickleType() + dialect = _make_dialect("spanner+spanner") + assert dpt.process_bind_param(None, dialect) is None + + def test_process_result_value_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("spanner+spanner") + original = {"key": "value", "nums": [1, 2, 3]} + pickled = pickle.dumps(original) + result = dpt.process_result_value(pickled, dialect) + assert result == original + + def test_process_result_value_mysql(self): + dpt = DynamicPickleType() + dialect = _make_dialect("mysql") + original = {"key": "value", "nums": [1, 2, 3]} + pickled = pickle.dumps(original) + result = dpt.process_result_value(pickled, dialect) + assert result == original + + def test_process_result_value_non_spanner(self): + dpt = DynamicPickleType() + dialect = _make_dialect("sqlite") + value = {"key": "value"} + result = dpt.process_result_value(value, dialect) + assert result == value + + def test_process_result_value_none(self): + dpt = DynamicPickleType() + dialect = _make_dialect("spanner+spanner") + assert dpt.process_result_value(None, dialect) is None + + +# --------------------------------------------------------------------------- +# SpannerPickleType TypeDecorator +# --------------------------------------------------------------------------- + + +class TestSpannerPickleType: + + def test_impl_is_pickle_type(self): + assert SpannerPickleType.impl is PickleType + + def test_bind_processor_encodes_base64(self): + spt = SpannerPickleType() + dialect = _make_dialect("spanner+spanner") + processor = spt.bind_processor(dialect) + + raw = b"some pickled data" + result = processor(raw) + assert result == base64.standard_b64encode(raw) + + def test_bind_processor_none(self): + spt = SpannerPickleType() + dialect = _make_dialect("spanner+spanner") + processor = spt.bind_processor(dialect) + assert processor(None) is None + + def test_result_processor_decodes_base64(self): + spt = SpannerPickleType() + dialect = _make_dialect("spanner+spanner") + processor = spt.result_processor(dialect, None) + + raw = b"some pickled data" + encoded = base64.standard_b64encode(raw) + result = processor(encoded) + assert result == raw + + def test_result_processor_none(self): + spt = SpannerPickleType() + dialect = _make_dialect("spanner+spanner") + processor = spt.result_processor(dialect, None) + assert processor(None) is None + + def test_roundtrip_bind_result(self): + spt = SpannerPickleType() + dialect = _make_dialect("spanner+spanner") + bind = spt.bind_processor(dialect) + result = spt.result_processor(dialect, None) + + original = pickle.dumps({"hello": "world"}) + encoded = bind(original) + decoded = result(encoded) + assert decoded == original + assert pickle.loads(decoded) == {"hello": "world"} + + +# --------------------------------------------------------------------------- +# StorageData DeclarativeBase +# --------------------------------------------------------------------------- + + +class TestStorageData: + + def test_is_declarative_base(self): + assert issubclass(StorageData, DeclarativeBase) + + def test_has_metadata(self): + assert StorageData.metadata is not None + + def test_has_registry(self): + assert StorageData.registry is not None + + +# --------------------------------------------------------------------------- +# Package re-exports +# --------------------------------------------------------------------------- + + +class TestSqlCommonReexports: + + def test_all_symbols_reexported(self): + from trpc_agent_sdk.storage import ( + DynamicJSON as _DJ, + DynamicJSONOptions as _DJO, + DynamicPickleType as _DPT, + PreciseTimestamp as _PT, + SpannerPickleType as _SPT, + StorageData as _SD, + UTF8MB4String as _U, + decode_content as _dc, + decode_grounding_metadata as _dg, + ) + + assert _DJ is DynamicJSON + assert _DJO is DynamicJSONOptions + assert _DPT is DynamicPickleType + assert _PT is PreciseTimestamp + assert _SPT is SpannerPickleType + assert _SD is StorageData + assert _U is UTF8MB4String + assert _dc is decode_content + assert _dg is decode_grounding_metadata + + +def _build_dialect(name: str) -> SimpleNamespace: + return SimpleNamespace(name=name, type_descriptor=lambda t: t) + + +@pytest.fixture(autouse=True) +def reset_hook_registry() -> Iterator[None]: + """Reset global hook registry around each test.""" + old_load = deepcopy(TypeDecoratorHookRegistry._load_dialect_hooks) + old_bind = deepcopy(TypeDecoratorHookRegistry._process_bind_hooks) + old_result = deepcopy(TypeDecoratorHookRegistry._process_result_hooks) + try: + TypeDecoratorHookRegistry._load_dialect_hooks = {} + TypeDecoratorHookRegistry._process_bind_hooks = {} + TypeDecoratorHookRegistry._process_result_hooks = {} + yield + finally: + TypeDecoratorHookRegistry._load_dialect_hooks = old_load + TypeDecoratorHookRegistry._process_bind_hooks = old_bind + TypeDecoratorHookRegistry._process_result_hooks = old_result + + +def test_dynamic_json_all_hooks_can_override() -> None: + """DynamicJSON supports load/bind/result hook overrides.""" + json_type = DynamicJSON() + sqlite = _build_dialect("sqlite") + load_marker = object() + + def load_hook(decorator, dialect): # noqa: ANN001 + assert decorator is json_type + assert dialect.name == "sqlite" + return load_marker + + def bind_hook(decorator, value, dialect): # noqa: ANN001 + assert decorator is json_type + assert dialect.name == "sqlite" + return f"hooked-bind-{value}" + + def result_hook(decorator, value, dialect): # noqa: ANN001 + assert decorator is json_type + assert dialect.name == "sqlite" + return {"hooked_result": value} + + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_load_dialect_hook(DynamicJSON, load_hook) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(DynamicJSON, bind_hook) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_result_hook(DynamicJSON, result_hook) + + assert json_type.load_dialect_impl(sqlite) is load_marker + assert json_type.process_bind_param({"k": "v"}, sqlite) == "hooked-bind-{'k': 'v'}" + assert json_type.process_result_value('{"k":"v"}', sqlite) == {"hooked_result": '{"k":"v"}'} + + +def test_dynamic_json_hook_none_falls_back_to_default_logic() -> None: + """Hook skips when returning None.""" + json_type = DynamicJSON() + sqlite = _build_dialect("sqlite") + + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(DynamicJSON, lambda _d, _v, _dialect: None) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_result_hook(DynamicJSON, lambda _d, _v, _dialect: None) + + encoded = json_type.process_bind_param({"a": 1}, sqlite) + decoded = json_type.process_result_value(encoded, sqlite) + + assert encoded == '{"a": 1}' + assert decoded == {"a": 1} + + +def test_precise_timestamp_supports_all_three_hooks() -> None: + """PreciseTimestamp supports load/bind/result hooks.""" + ts_type = PreciseTimestamp() + sqlite = _build_dialect("sqlite") + load_marker = object() + + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_load_dialect_hook(PreciseTimestamp, lambda _d, _dialect: load_marker) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(PreciseTimestamp, + lambda _d, value, _dialect: f"bind-{value}") + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_result_hook(PreciseTimestamp, + lambda _d, value, _dialect: f"result-{value}") + + assert ts_type.load_dialect_impl(sqlite) is load_marker + assert ts_type.process_bind_param("2026-01-01", sqlite) == "bind-2026-01-01" + assert ts_type.process_result_value("2026-01-01", sqlite) == "result-2026-01-01" + + +def test_utf8mb4_string_supports_all_three_hooks() -> None: + """UTF8MB4String supports load/bind/result hooks.""" + str_type = UTF8MB4String(length=128) + sqlite = _build_dialect("sqlite") + load_marker = object() + + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_load_dialect_hook(UTF8MB4String, lambda _d, _dialect: load_marker) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(UTF8MB4String, + lambda _d, value, _dialect: f"bind-{value}") + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_result_hook(UTF8MB4String, + lambda _d, value, _dialect: f"result-{value}") + + assert str_type.load_dialect_impl(sqlite) is load_marker + assert str_type.process_bind_param("hello", sqlite) == "bind-hello" + assert str_type.process_result_value("hello", sqlite) == "result-hello" + + +def test_dynamic_pickle_hook_order_uses_first_override_result() -> None: + """First non-None hook result wins for DynamicPickleType.""" + pickle_type = DynamicPickleType() + sqlite = _build_dialect("sqlite") + calls: list[str] = [] + + def hook_a(_d, _v, _dialect): # noqa: ANN001 + calls.append("a") + return None + + def hook_b(_d, _v, _dialect): # noqa: ANN001 + calls.append("b") + return "override" + + def hook_c(_d, _v, _dialect): # noqa: ANN001 + calls.append("c") + return "should-not-run" + + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(DynamicPickleType, hook_a) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(DynamicPickleType, hook_b) + GLOBAL_TYPE_DECORATOR_HOOK_REGISTRY.register_process_bind_hook(DynamicPickleType, hook_c) + + assert pickle_type.process_bind_param({"k": "v"}, sqlite) == "override" + assert calls == ["a", "b"] diff --git a/tests/teams/__init__.py b/tests/teams/__init__.py new file mode 100644 index 000000000..e31b491b6 --- /dev/null +++ b/tests/teams/__init__.py @@ -0,0 +1,6 @@ +# 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 trpc_agent_sdk.teams module.""" diff --git a/tests/teams/test_delegation_signal.py b/tests/teams/test_delegation_signal.py new file mode 100644 index 000000000..46ce91a1f --- /dev/null +++ b/tests/teams/test_delegation_signal.py @@ -0,0 +1,248 @@ +# 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 DelegationSignal.""" + +import pytest +from trpc_agent_sdk.teams.core import DELEGATION_SIGNAL_MARKER +from trpc_agent_sdk.teams.core import DelegationSignal + + +class TestDelegationSignalBasic: + """Tests for DelegationSignal basic functionality.""" + + def test_default_values(self): + """Test DelegationSignal initializes with correct defaults.""" + signal = DelegationSignal() + assert signal.marker == DELEGATION_SIGNAL_MARKER + assert signal.action == "delegate_to_member" + assert signal.member_name == "" + assert signal.task == "" + + def test_create_with_values(self): + """Test creating DelegationSignal with custom values.""" + signal = DelegationSignal( + member_name="researcher", + task="Find information about AI", + ) + assert signal.member_name == "researcher" + assert signal.task == "Find information about AI" + assert signal.marker == DELEGATION_SIGNAL_MARKER + + def test_delegate_to_all_action(self): + """Test DelegationSignal with delegate_to_all action.""" + signal = DelegationSignal( + action="delegate_to_all", + task="Process data", + ) + assert signal.action == "delegate_to_all" + + +class TestDelegationSignalDetection: + """Tests for DelegationSignal detection methods.""" + + def test_is_delegation_signal_valid(self): + """Test detecting valid delegation signal.""" + response_data = { + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "researcher", + "task": "Do something", + } + assert DelegationSignal.is_delegation_signal(response_data) is True + + def test_is_delegation_signal_invalid_marker(self): + """Test detecting invalid marker.""" + response_data = { + "marker": "wrong_marker", + "action": "delegate_to_member", + } + assert DelegationSignal.is_delegation_signal(response_data) is False + + def test_is_delegation_signal_missing_marker(self): + """Test detecting missing marker.""" + response_data = { + "action": "delegate_to_member", + "member_name": "researcher", + } + assert DelegationSignal.is_delegation_signal(response_data) is False + + def test_is_delegation_signal_non_dict(self): + """Test that non-dict input returns False.""" + assert DelegationSignal.is_delegation_signal(None) is False + assert DelegationSignal.is_delegation_signal([]) is False + assert DelegationSignal.is_delegation_signal(123) is False + + def test_is_delegation_signal_json_string_valid(self): + """Test detecting valid delegation signal from JSON string.""" + import json + json_string = json.dumps({ + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "researcher", + "task": "Do something", + }) + assert DelegationSignal.is_delegation_signal(json_string) is True + + def test_is_delegation_signal_json_string_invalid_marker(self): + """Test detecting invalid marker from JSON string.""" + import json + json_string = json.dumps({ + "marker": "wrong_marker", + "action": "delegate_to_member", + }) + assert DelegationSignal.is_delegation_signal(json_string) is False + + def test_is_delegation_signal_invalid_json_string(self): + """Test that invalid JSON string returns False.""" + assert DelegationSignal.is_delegation_signal("not valid json") is False + assert DelegationSignal.is_delegation_signal("") is False + assert DelegationSignal.is_delegation_signal("{invalid}") is False + + def test_is_delegation_signal_json_string_array(self): + """Test that JSON array string returns False.""" + import json + assert DelegationSignal.is_delegation_signal(json.dumps([])) is False + assert DelegationSignal.is_delegation_signal(json.dumps([1, 2, 3])) is False + + def test_is_delegation_signal_delegation_instance(self): + """Test detecting DelegationSignal instance directly.""" + signal = DelegationSignal( + member_name="researcher", + task="Do something", + ) + assert DelegationSignal.is_delegation_signal(signal) is True + + +class TestDelegationSignalFromResponse: + """Tests for creating DelegationSignal from response data.""" + + def test_from_response_full_data(self): + """Test creating signal from full response data.""" + response_data = { + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "writer", + "task": "Write an article", + } + signal = DelegationSignal.from_response(response_data) + + assert signal.marker == DELEGATION_SIGNAL_MARKER + assert signal.action == "delegate_to_member" + assert signal.member_name == "writer" + assert signal.task == "Write an article" + + def test_from_response_partial_data(self): + """Test creating signal from partial response data.""" + response_data = { + "marker": DELEGATION_SIGNAL_MARKER, + "task": "Do something", + } + signal = DelegationSignal.from_response(response_data) + + assert signal.marker == DELEGATION_SIGNAL_MARKER + assert signal.action == "delegate_to_member" # default + assert signal.member_name == "" # default + assert signal.task == "Do something" + + def test_from_response_empty_data(self): + """Test creating signal from empty response data.""" + signal = DelegationSignal.from_response({}) + + assert signal.marker == DELEGATION_SIGNAL_MARKER + assert signal.action == "delegate_to_member" + assert signal.member_name == "" + assert signal.task == "" + + def test_from_response_json_string(self): + """Test creating signal from JSON string.""" + import json + json_string = json.dumps({ + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "researcher", + "task": "Research topic", + }) + signal = DelegationSignal.from_response(json_string) + + assert signal is not None + assert signal.marker == DELEGATION_SIGNAL_MARKER + assert signal.member_name == "researcher" + assert signal.task == "Research topic" + + def test_from_response_json_string_partial(self): + """Test creating signal from partial JSON string.""" + import json + json_string = json.dumps({ + "marker": DELEGATION_SIGNAL_MARKER, + "task": "Do something", + }) + signal = DelegationSignal.from_response(json_string) + + assert signal is not None + assert signal.task == "Do something" + assert signal.member_name == "" # default + assert signal.action == "delegate_to_member" # default + + def test_from_response_invalid_json_string(self): + """Test that invalid JSON string returns None.""" + assert DelegationSignal.from_response("not valid json") is None + assert DelegationSignal.from_response("") is None + assert DelegationSignal.from_response("{invalid}") is None + + def test_from_response_json_array_string(self): + """Test that JSON array string returns None.""" + import json + assert DelegationSignal.from_response(json.dumps([])) is None + assert DelegationSignal.from_response(json.dumps([1, 2, 3])) is None + + +class TestDelegationSignalSerialization: + """Tests for DelegationSignal serialization.""" + + def test_model_dump(self): + """Test Pydantic model serialization.""" + signal = DelegationSignal( + member_name="researcher", + task="Research topic", + ) + data = signal.model_dump() + + assert data["marker"] == DELEGATION_SIGNAL_MARKER + assert data["action"] == "delegate_to_member" + assert data["member_name"] == "researcher" + assert data["task"] == "Research topic" + + def test_roundtrip_serialization(self): + """Test serialization roundtrip.""" + original = DelegationSignal( + action="delegate_to_all", + member_name="", + task="Broadcast task", + ) + + # Serialize + data = original.model_dump() + + # Deserialize + restored = DelegationSignal.from_response(data) + + assert restored.marker == original.marker + assert restored.action == original.action + assert restored.member_name == original.member_name + assert restored.task == original.task + + +class TestDelegationSignalMarker: + """Tests for the delegation signal marker constant.""" + + def test_marker_value(self): + """Test that marker has expected value.""" + assert DELEGATION_SIGNAL_MARKER == "__TEAM_DELEGATION__" + + def test_marker_used_in_signal(self): + """Test that signal uses the correct marker.""" + signal = DelegationSignal() + assert signal.marker == DELEGATION_SIGNAL_MARKER diff --git a/tests/teams/test_delegation_tools.py b/tests/teams/test_delegation_tools.py new file mode 100644 index 000000000..d35e29672 --- /dev/null +++ b/tests/teams/test_delegation_tools.py @@ -0,0 +1,209 @@ +# 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 delegation tools.""" + +import pytest +from trpc_agent_sdk.teams.core import DELEGATION_SIGNAL_MARKER +from trpc_agent_sdk.teams.core import DelegationSignal +from trpc_agent_sdk.teams.core import DELEGATE_TOOL_NAME +from trpc_agent_sdk.teams.core import create_delegate_to_member_tool +from trpc_agent_sdk.tools import FunctionTool + + +class TestDelegateToolName: + """Tests for delegation tool name constant.""" + + def test_tool_name_value(self): + """Test that tool name has expected value.""" + assert DELEGATE_TOOL_NAME == "delegate_to_member" + + +class TestCreateDelegateToMemberTool: + """Tests for create_delegate_to_member_tool factory function.""" + + def test_returns_function_tool(self): + """Test that factory returns a FunctionTool.""" + tool = create_delegate_to_member_tool(["researcher", "writer"]) + assert isinstance(tool, FunctionTool) + + def test_tool_has_correct_name(self): + """Test that created tool has correct name.""" + tool = create_delegate_to_member_tool(["researcher"]) + # Get the underlying function name + assert tool.func.__name__ == "delegate_to_member" + + def test_tool_docstring_includes_members(self): + """Test that tool docstring includes member names.""" + members = ["researcher", "writer", "editor"] + tool = create_delegate_to_member_tool(members) + + docstring = tool.func.__doc__ + for member in members: + assert member in docstring + + def test_tool_docstring_with_single_member(self): + """Test tool docstring with single member.""" + tool = create_delegate_to_member_tool(["only_member"]) + assert "only_member" in tool.func.__doc__ + + +class TestDelegateToMemberToolExecution: + """Tests for executing the delegate_to_member tool.""" + + def test_returns_delegation_signal(self): + """Test that tool execution returns DelegationSignal.""" + tool = create_delegate_to_member_tool(["researcher", "writer"]) + result = tool.func("researcher", "Find information") + + assert isinstance(result, DelegationSignal) + + def test_signal_has_correct_action(self): + """Test that returned signal has correct action.""" + tool = create_delegate_to_member_tool(["researcher"]) + result = tool.func("researcher", "Task") + + assert result.action == "delegate_to_member" + + def test_signal_has_correct_member_name(self): + """Test that returned signal has correct member name.""" + tool = create_delegate_to_member_tool(["researcher", "writer"]) + result = tool.func("writer", "Write article") + + assert result.member_name == "writer" + + def test_signal_has_correct_task(self): + """Test that returned signal has correct task.""" + tool = create_delegate_to_member_tool(["researcher"]) + task = "Research the history of machine learning" + result = tool.func("researcher", task) + + assert result.task == task + + def test_signal_has_marker(self): + """Test that returned signal has the delegation marker.""" + tool = create_delegate_to_member_tool(["researcher"]) + result = tool.func("researcher", "Task") + + assert result.marker == DELEGATION_SIGNAL_MARKER + + +class TestDelegateToMemberToolWithVaryingInputs: + """Tests for delegate_to_member tool with various inputs.""" + + def test_empty_task(self): + """Test delegation with empty task.""" + tool = create_delegate_to_member_tool(["researcher"]) + result = tool.func("researcher", "") + + assert result.task == "" + assert result.member_name == "researcher" + + def test_long_task(self): + """Test delegation with long task description.""" + tool = create_delegate_to_member_tool(["researcher"]) + long_task = "A" * 10000 # 10k character task + result = tool.func("researcher", long_task) + + assert result.task == long_task + + def test_task_with_special_characters(self): + """Test delegation with special characters in task.""" + tool = create_delegate_to_member_tool(["researcher"]) + special_task = "Find info about & 'quotes' \"double\" \n\t special" + result = tool.func("researcher", special_task) + + assert result.task == special_task + + def test_task_with_unicode(self): + """Test delegation with unicode characters in task.""" + tool = create_delegate_to_member_tool(["researcher"]) + unicode_task = "Recherche sur l'IA et les modeles" + result = tool.func("researcher", unicode_task) + + assert result.task == unicode_task + + def test_member_name_not_in_list(self): + """Test that tool works even with member name not in list.""" + # Note: The tool doesn't validate member names, TeamAgent does + tool = create_delegate_to_member_tool(["researcher"]) + result = tool.func("unknown_member", "Task") + + # Should still return a valid signal + assert result.member_name == "unknown_member" + assert isinstance(result, DelegationSignal) + + +class TestDelegateToMemberToolSignalDetection: + """Tests for detecting delegation signal from tool result.""" + + def test_signal_is_detectable(self): + """Test that returned signal is detectable.""" + tool = create_delegate_to_member_tool(["researcher"]) + result = tool.func("researcher", "Task") + + # Convert to dict and check detection + result_dict = result.model_dump() + assert DelegationSignal.is_delegation_signal(result_dict) is True + + def test_signal_roundtrip(self): + """Test signal can be serialized and deserialized.""" + tool = create_delegate_to_member_tool(["researcher", "writer"]) + original = tool.func("writer", "Write an essay") + + # Serialize and deserialize + data = original.model_dump() + restored = DelegationSignal.from_response(data) + + assert restored.member_name == original.member_name + assert restored.task == original.task + assert restored.action == original.action + + +class TestDelegateToMemberToolEdgeCases: + """Tests for edge cases in delegation tool creation.""" + + def test_empty_member_list(self): + """Test creating tool with empty member list.""" + tool = create_delegate_to_member_tool([]) + assert isinstance(tool, FunctionTool) + result = tool.func("any_member", "Task") + assert isinstance(result, DelegationSignal) + assert result.member_name == "any_member" + + def test_single_member_list(self): + """Test creating tool with single member.""" + tool = create_delegate_to_member_tool(["only_one"]) + assert "only_one" in tool.func.__doc__ + result = tool.func("only_one", "Solo task") + assert result.member_name == "only_one" + + +class TestCreateMultipleTools: + """Tests for creating multiple delegation tools.""" + + def test_different_member_lists(self): + """Test creating tools with different member lists.""" + tool1 = create_delegate_to_member_tool(["member_alpha", "member_beta"]) + tool2 = create_delegate_to_member_tool(["member_gamma", "member_delta", "member_epsilon"]) + + # Each tool should have its own docstring with their specific members + assert "member_alpha" in tool1.func.__doc__ + assert "member_gamma" in tool2.func.__doc__ + # Members from one list should not be in other tool's docstring + assert "member_gamma" not in tool1.func.__doc__ + assert "member_alpha" not in tool2.func.__doc__ + + def test_tools_are_independent(self): + """Test that created tools are independent instances.""" + tool1 = create_delegate_to_member_tool(["member1"]) + tool2 = create_delegate_to_member_tool(["member2"]) + + # Results should be independent + result1 = tool1.func("member1", "Task 1") + result2 = tool2.func("member2", "Task 2") + + assert result1.member_name == "member1" + assert result2.member_name == "member2" diff --git a/tests/teams/test_member_message_filter.py b/tests/teams/test_member_message_filter.py new file mode 100644 index 000000000..5b02a3b96 --- /dev/null +++ b/tests/teams/test_member_message_filter.py @@ -0,0 +1,306 @@ +# 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 member message filters.""" + +import pytest +from trpc_agent_sdk.teams.core import keep_all_member_message +from trpc_agent_sdk.teams.core import keep_last_member_message +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +class TestKeepAllMemberMessage: + """Tests for keep_all_member_message filter.""" + + @pytest.mark.asyncio + async def test_empty_messages(self): + """Test with empty message list.""" + result = await keep_all_member_message([]) + assert result == "" + + @pytest.mark.asyncio + async def test_single_text_message(self): + """Test with single text message.""" + content = Content(role="model", parts=[Part.from_text(text="Hello world")]) + result = await keep_all_member_message([content]) + assert result == "Hello world" + + @pytest.mark.asyncio + async def test_multiple_text_messages(self): + """Test with multiple text messages.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="First message")]), + Content(role="model", parts=[Part.from_text(text="Second message")]), + ] + result = await keep_all_member_message(messages) + assert "First message" in result + assert "Second message" in result + + @pytest.mark.asyncio + async def test_multiple_parts_in_message(self): + """Test message with multiple parts.""" + content = Content( + role="model", + parts=[ + Part.from_text(text="Part 1"), + Part.from_text(text="Part 2"), + ], + ) + result = await keep_all_member_message([content]) + assert "Part 1" in result + assert "Part 2" in result + + @pytest.mark.asyncio + async def test_skips_thought_content(self): + """Test that thought content is skipped.""" + content = Content( + role="model", + parts=[ + Part(text="Visible text", thought=False), + Part(text="Hidden thought", thought=True), + ], + ) + result = await keep_all_member_message([content]) + assert "Visible text" in result + assert "Hidden thought" not in result + + @pytest.mark.asyncio + async def test_includes_function_call_as_text(self): + """Test that function calls are converted to text.""" + from trpc_agent_sdk.types import FunctionCall + + function_call = FunctionCall(name="search", args={"query": "test"}) + content = Content( + role="model", + parts=[Part(function_call=function_call)], + ) + result = await keep_all_member_message([content]) + assert "[Tool Call:" in result + assert "search" in result + + @pytest.mark.asyncio + async def test_includes_function_response_as_text(self): + """Test that function responses are converted to text.""" + from trpc_agent_sdk.types import FunctionResponse + + function_response = FunctionResponse( + name="search", + response={"result": "found"}, + id="123", + ) + content = Content( + role="model", + parts=[Part(function_response=function_response)], + ) + result = await keep_all_member_message([content]) + assert "[Tool Result:" in result + + @pytest.mark.asyncio + async def test_none_content_in_list(self): + """Test handling of None content in list.""" + messages = [ + None, + Content(role="model", parts=[Part.from_text(text="Valid message")]), + ] + result = await keep_all_member_message(messages) + assert "Valid message" in result + + @pytest.mark.asyncio + async def test_content_with_no_parts(self): + """Test content with no parts.""" + content = Content(role="model", parts=[]) + result = await keep_all_member_message([content]) + assert result == "" + + @pytest.mark.asyncio + async def test_content_with_none_parts(self): + """Test content with None parts.""" + content = Content(role="model", parts=None) + result = await keep_all_member_message([content]) + assert result == "" + + @pytest.mark.asyncio + async def test_messages_joined_with_newlines(self): + """Test that messages are joined with newlines.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="Line 1")]), + Content(role="model", parts=[Part.from_text(text="Line 2")]), + ] + result = await keep_all_member_message(messages) + assert "\n" in result + + +class TestKeepLastMemberMessage: + """Tests for keep_last_member_message filter.""" + + @pytest.mark.asyncio + async def test_empty_messages(self): + """Test with empty message list.""" + result = await keep_last_member_message([]) + assert result == "" + + @pytest.mark.asyncio + async def test_single_text_message(self): + """Test with single text message.""" + content = Content(role="model", parts=[Part.from_text(text="Only message")]) + result = await keep_last_member_message([content]) + assert result == "Only message" + + @pytest.mark.asyncio + async def test_returns_last_text_message(self): + """Test that only the last text message is returned.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="First message")]), + Content(role="model", parts=[Part.from_text(text="Second message")]), + Content(role="model", parts=[Part.from_text(text="Last message")]), + ] + result = await keep_last_member_message(messages) + assert result == "Last message" + assert "First message" not in result + assert "Second message" not in result + + @pytest.mark.asyncio + async def test_skips_tool_only_messages(self): + """Test that it finds last message with actual text, skipping tool-only.""" + from trpc_agent_sdk.types import FunctionCall, FunctionResponse + + messages = [ + Content(role="model", parts=[Part.from_text(text="Real response")]), + Content( + role="model", + parts=[Part(function_call=FunctionCall(name="tool", args={}))], + ), + Content( + role="model", + parts=[Part(function_response=FunctionResponse(name="tool", response={}, id="1"))], + ), + ] + result = await keep_last_member_message(messages) + # Should return the text message, not the tool messages + assert result == "Real response" + + @pytest.mark.asyncio + async def test_skips_thought_content(self): + """Test that thought content is skipped.""" + content = Content( + role="model", + parts=[ + Part(text="Visible", thought=False), + Part(text="Hidden thought", thought=True), + ], + ) + result = await keep_last_member_message([content]) + assert "Visible" in result + assert "Hidden thought" not in result + + @pytest.mark.asyncio + async def test_multiple_parts_in_last_message(self): + """Test last message with multiple text parts.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="Ignored")]), + Content( + role="model", + parts=[ + Part.from_text(text="Part A"), + Part.from_text(text="Part B"), + ], + ), + ] + result = await keep_last_member_message(messages) + assert "Part A" in result + assert "Part B" in result + assert "Ignored" not in result + + @pytest.mark.asyncio + async def test_none_content_in_list(self): + """Test handling of None content in list.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="Valid")]), + None, + ] + result = await keep_last_member_message(messages) + # Should fall back to the valid message + assert result == "Valid" + + @pytest.mark.asyncio + async def test_content_with_no_parts(self): + """Test content with empty parts.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[]), + ] + result = await keep_last_member_message(messages) + # Should fall back to the first message + assert result == "First" + + @pytest.mark.asyncio + async def test_all_tool_messages_returns_empty(self): + """Test when all messages are tool-only, returns empty.""" + from trpc_agent_sdk.types import FunctionCall + + messages = [ + Content( + role="model", + parts=[Part(function_call=FunctionCall(name="tool1", args={}))], + ), + Content( + role="model", + parts=[Part(function_call=FunctionCall(name="tool2", args={}))], + ), + ] + result = await keep_last_member_message(messages) + assert result == "" + + @pytest.mark.asyncio + async def test_text_parts_joined(self): + """Test that multiple text parts in last message are joined.""" + content = Content( + role="model", + parts=[ + Part.from_text(text="Line 1"), + Part.from_text(text="Line 2"), + ], + ) + result = await keep_last_member_message([content]) + assert "\n" in result + assert "Line 1" in result + assert "Line 2" in result + + +class TestFilterComparison: + """Tests comparing keep_all vs keep_last filters.""" + + @pytest.mark.asyncio + async def test_all_vs_last_different_results(self): + """Test that the two filters give different results.""" + messages = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[Part.from_text(text="Second")]), + Content(role="model", parts=[Part.from_text(text="Third")]), + ] + + all_result = await keep_all_member_message(messages) + last_result = await keep_last_member_message(messages) + + # All should contain all three + assert "First" in all_result + assert "Second" in all_result + assert "Third" in all_result + + # Last should only contain the last one + assert last_result == "Third" + assert "First" not in last_result + assert "Second" not in last_result + + @pytest.mark.asyncio + async def test_all_vs_last_same_for_single_message(self): + """Test that both filters give same result for single message.""" + messages = [Content(role="model", parts=[Part.from_text(text="Only one")])] + + all_result = await keep_all_member_message(messages) + last_result = await keep_last_member_message(messages) + + assert all_result == last_result == "Only one" diff --git a/tests/teams/test_message_builder.py b/tests/teams/test_message_builder.py new file mode 100644 index 000000000..9558c8de2 --- /dev/null +++ b/tests/teams/test_message_builder.py @@ -0,0 +1,523 @@ +# 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 TeamMessageBuilder.""" + +import pytest +from trpc_agent_sdk.teams.core import TeamMessageBuilder +from trpc_agent_sdk.teams.core import TeamRunContext + + +class TestTeamMessageBuilderInit: + """Tests for TeamMessageBuilder initialization.""" + + def test_default_values(self): + """Test default initialization values.""" + builder = TeamMessageBuilder() + assert builder.share_team_history is False + assert builder.num_team_history_runs == 3 + assert builder.share_member_interactions is False + assert builder.num_member_history_runs == 0 + assert builder.add_history_to_leader is True + assert builder.num_leader_history_runs == 3 + + def test_custom_values(self): + """Test initialization with custom values.""" + builder = TeamMessageBuilder( + share_team_history=True, + num_team_history_runs=5, + share_member_interactions=True, + num_member_history_runs=2, + add_history_to_leader=False, + num_leader_history_runs=10, + ) + assert builder.share_team_history is True + assert builder.num_team_history_runs == 5 + assert builder.share_member_interactions is True + assert builder.num_member_history_runs == 2 + assert builder.add_history_to_leader is False + assert builder.num_leader_history_runs == 10 + + +class TestBuildMemberMessages: + """Tests for building member agent messages.""" + + def test_build_member_messages_basic(self): + """Test building basic member messages with just a task.""" + builder = TeamMessageBuilder() + ctx = TeamRunContext() + task = "Research the topic of AI" + + messages = builder.build_member_messages(task=task, team_run_context=ctx) + + assert len(messages) == 1 + assert messages[0].role == "user" + assert len(messages[0].parts) == 1 + assert messages[0].parts[0].text == task + + def test_build_member_messages_with_team_history(self): + """Test building member messages with team history sharing enabled.""" + builder = TeamMessageBuilder(share_team_history=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Initial question") + ctx.add_leader_message("model", "Initial response") + task = "Do something" + + messages = builder.build_member_messages(task=task, team_run_context=ctx) + + assert len(messages) == 1 + content = messages[0].parts[0].text + assert "" in content + assert "Initial question" in content + assert "Initial response" in content + assert task in content + + def test_build_member_messages_with_member_interactions(self): + """Test building member messages with member interaction sharing.""" + builder = TeamMessageBuilder(share_member_interactions=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Find data", "Found data about AI") + task = "Write summary" + + messages = builder.build_member_messages(task=task, team_run_context=ctx) + + assert len(messages) == 1 + content = messages[0].parts[0].text + assert "" in content + assert "researcher" in content + assert "Found data about AI" in content + assert task in content + + def test_build_member_messages_member_self_history_disabled_by_default(self): + """Test member self history is disabled when num_member_history_runs=0.""" + builder = TeamMessageBuilder(num_member_history_runs=0) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Find data", "Found data") + + messages = builder.build_member_messages( + task="Write summary", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "" not in content + assert "Write summary" in content + + def test_build_member_messages_with_member_self_history_current_run(self): + """Test member self history includes same-member records from current run.""" + builder = TeamMessageBuilder(num_member_history_runs=1) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "First task", "First response") + ctx.add_interaction("writer", "Writer task", "Writer response") + + messages = builder.build_member_messages( + task="Second task", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "" in content + assert "Task: First task" in content + assert "Response: First response" in content + assert "Task: Writer task" not in content + + def test_build_member_messages_member_self_history_limited_by_runs(self): + """Test member self history respects num_member_history_runs.""" + builder = TeamMessageBuilder(num_member_history_runs=1) + ctx = TeamRunContext() + + ctx.current_invocation_id = "inv-1" + ctx.add_interaction("researcher", "Old task", "Old response") + + ctx.current_invocation_id = "inv-2" + ctx.add_interaction("researcher", "New task", "New response") + + messages = builder.build_member_messages( + task="Current task", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "Task: Old task" not in content + assert "Task: New task" in content + + def test_build_member_messages_self_history_avoids_duplicates_in_shared_interactions(self): + """Test shared interactions exclude self entries when self history is enabled.""" + builder = TeamMessageBuilder( + share_member_interactions=True, + num_member_history_runs=1, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Self task", "Self response") + ctx.add_interaction("writer", "Peer task", "Peer response") + + messages = builder.build_member_messages( + task="Current task", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "" in content + assert "" in content + assert "Member: writer" in content + assert "Member: researcher" not in content + assert "Task: Self task" in content + assert "Response: Self response" in content + + def test_build_member_messages_no_history_when_disabled(self): + """Test that history is not included when sharing is disabled.""" + builder = TeamMessageBuilder( + share_team_history=False, + share_member_interactions=False, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Should not appear") + ctx.add_interaction("researcher", "Task", "Response") + task = "Do task" + + messages = builder.build_member_messages(task=task, team_run_context=ctx) + + content = messages[0].parts[0].text + assert "" not in content + assert "" not in content + assert content == task + + +class TestBuildLeaderMessages: + """Tests for building team leader messages.""" + + def test_build_leader_messages_empty_history(self): + """Test building leader messages with no history.""" + builder = TeamMessageBuilder(add_history_to_leader=True) + ctx = TeamRunContext() + + messages = builder.build_leader_messages(team_run_context=ctx) + + # Should return empty list when no history + assert messages == [] + + def test_build_leader_messages_with_history(self): + """Test building leader messages with conversation history.""" + builder = TeamMessageBuilder(add_history_to_leader=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "What is AI?") + ctx.add_leader_message("model", "AI is artificial intelligence") + + messages = builder.build_leader_messages(team_run_context=ctx) + + assert len(messages) == 1 + content = messages[0].parts[0].text + assert "" in content + assert "What is AI?" in content + assert "AI is artificial intelligence" in content + + def test_build_leader_messages_history_disabled(self): + """Test that leader history is not included when disabled.""" + builder = TeamMessageBuilder(add_history_to_leader=False) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Should not appear") + + messages = builder.build_leader_messages(team_run_context=ctx) + + # Should return empty list when history disabled + assert messages == [] + + def test_build_leader_messages_includes_transition_prompt(self): + """Test that leader messages include the transition prompt.""" + builder = TeamMessageBuilder(add_history_to_leader=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Question") + + messages = builder.build_leader_messages(team_run_context=ctx) + + content = messages[0].parts[0].text + # Check for the transition prompt content + assert "think about whether" in content.lower() or "finished" in content.lower() + + +class TestTeamHistoryFormatting: + """Tests for team history formatting.""" + + def test_team_history_formatting_user_messages(self): + """Test that user messages are formatted correctly in history.""" + builder = TeamMessageBuilder(share_team_history=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "User question here") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "User: User question here" in content + + def test_team_history_formatting_model_messages(self): + """Test that model messages are formatted correctly in history.""" + builder = TeamMessageBuilder(share_team_history=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("model", "Model response here") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "Assistant: Model response here" in content + + def test_team_history_xml_tags(self): + """Test that team history is wrapped in XML tags.""" + builder = TeamMessageBuilder(share_team_history=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Question") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert content.count("") == 1 + assert content.count("") == 1 + + +class TestMemberInteractionsFormatting: + """Tests for member interactions formatting.""" + + def test_member_interactions_formatting(self): + """Test that member interactions are formatted correctly.""" + builder = TeamMessageBuilder(share_member_interactions=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Find info", "Found info") + ctx.add_interaction("writer", "Write doc", "Doc written") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "Member: researcher" in content + assert "Task: Find info" in content + assert "Response: Found info" in content + assert "Member: writer" in content + + def test_member_interactions_xml_tags(self): + """Test that member interactions are wrapped in XML tags.""" + builder = TeamMessageBuilder(share_member_interactions=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task", "Response") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "" in content + assert "" in content + + def test_member_interactions_only_current_run(self): + """Test that only current run interactions are included.""" + builder = TeamMessageBuilder(share_member_interactions=True) + ctx = TeamRunContext() + + # Add interaction from previous invocation + ctx.current_invocation_id = "inv-1" + ctx.add_interaction("old_member", "Old task", "Old response") + + # Add interaction from current invocation + ctx.current_invocation_id = "inv-2" + ctx.add_interaction("new_member", "New task", "New response") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "new_member" in content + assert "old_member" not in content + + +class TestHistoryLimiting: + """Tests for history run limiting.""" + + def test_leader_history_limited_by_runs(self): + """Test that leader history is limited by num_leader_history_runs.""" + builder = TeamMessageBuilder( + add_history_to_leader=True, + num_team_history_runs=1, # Member history limit + num_leader_history_runs=2, # Leader history limit + ) + ctx = TeamRunContext() + + # Add history from 3 different invocations + for i in range(1, 4): + ctx.current_invocation_id = f"inv-{i}" + ctx.add_leader_message("user", f"Question {i}") + ctx.add_leader_message("model", f"Answer {i}") + + messages = builder.build_leader_messages(team_run_context=ctx) + + content = messages[0].parts[0].text + # Should only include last 2 runs (inv-2 and inv-3) + assert "Question 1" not in content + assert "Question 2" in content + assert "Question 3" in content + + def test_team_history_limited_by_runs(self): + """Test that team history for members is limited by num_team_history_runs.""" + builder = TeamMessageBuilder( + share_team_history=True, + num_team_history_runs=1, + ) + ctx = TeamRunContext() + + # Add history from 2 different invocations + ctx.current_invocation_id = "inv-1" + ctx.add_leader_message("user", "Old question") + + ctx.current_invocation_id = "inv-2" + ctx.add_leader_message("user", "New question") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + # Should only include last 1 run + assert "Old question" not in content + assert "New question" in content + + +class TestMemberInteractionsExclusion: + """Tests for member interaction exclusion logic.""" + + def test_exclude_member_removes_self_from_interactions(self): + """Test that excluding a member removes only their entries when self-history is enabled.""" + builder = TeamMessageBuilder( + share_member_interactions=True, + num_member_history_runs=1, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task A", "Response A") + ctx.add_interaction("writer", "Task B", "Response B") + + messages = builder.build_member_messages( + task="Task", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "Member: writer" in content + assert "Member: researcher" not in content + + def test_exclude_member_all_interactions_filtered(self): + """Test when all current-run interactions belong to excluded member.""" + builder = TeamMessageBuilder( + share_member_interactions=True, + num_member_history_runs=1, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task A", "Response A") + + messages = builder.build_member_messages( + task="Do work", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "" not in content + assert "Do work" in content + + def test_no_member_interactions_returns_empty(self): + """Test member interactions when there are no interactions.""" + builder = TeamMessageBuilder(share_member_interactions=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "" not in content + + +class TestMemberSelfHistoryDirect: + """Tests for member self history edge cases.""" + + def test_member_self_history_no_history(self): + """Test member self history when member has no history.""" + builder = TeamMessageBuilder(num_member_history_runs=3) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("writer", "Task", "Response") + + messages = builder.build_member_messages( + task="Do work", + team_run_context=ctx, + member_name="researcher", + ) + + content = messages[0].parts[0].text + assert "" not in content + + def test_member_self_history_without_member_name(self): + """Test member self history is not added when no member_name.""" + builder = TeamMessageBuilder(num_member_history_runs=3) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task", "Response") + + messages = builder.build_member_messages( + task="Do work", + team_run_context=ctx, + ) + + content = messages[0].parts[0].text + assert "" not in content + + +class TestTeamHistoryWhitespace: + """Tests for team history with edge-case text content.""" + + def test_whitespace_only_entries_skipped(self): + """Test that whitespace-only history entries are skipped.""" + builder = TeamMessageBuilder(share_team_history=True) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.leader_history = [ + {"role": "user", "text": " ", "invocation_id": "inv-1"}, + {"role": "model", "text": "Real response", "invocation_id": "inv-1"}, + ] + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + assert "Real response" in content + + +class TestMessageCombination: + """Tests for combining different message parts.""" + + def test_all_parts_combined(self): + """Test that all enabled parts are combined in correct order.""" + builder = TeamMessageBuilder( + share_team_history=True, + share_member_interactions=True, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "History message") + ctx.add_interaction("member", "Task done", "Response here") + task = "Final task" + + messages = builder.build_member_messages(task=task, team_run_context=ctx) + + content = messages[0].parts[0].text + # Check order: interactions first, then history, then task + interaction_pos = content.find("") + history_pos = content.find("") + task_pos = content.find(task) + + assert interaction_pos < history_pos < task_pos + + def test_parts_separated_by_newlines(self): + """Test that parts are properly separated.""" + builder = TeamMessageBuilder( + share_team_history=True, + share_member_interactions=True, + ) + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "History") + ctx.add_interaction("member", "Task", "Response") + + messages = builder.build_member_messages(task="Task", team_run_context=ctx) + + content = messages[0].parts[0].text + # Should have double newlines between sections + assert "\n\n" in content diff --git a/tests/teams/test_system_message.py b/tests/teams/test_system_message.py new file mode 100644 index 000000000..65979d203 --- /dev/null +++ b/tests/teams/test_system_message.py @@ -0,0 +1,354 @@ +# 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 system message generation.""" + +from unittest.mock import Mock + +from trpc_agent_sdk.teams.core import generate_team_leader_system_message +from trpc_agent_sdk.teams.core import get_member_info_list + + +class TestGenerateTeamLeaderSystemMessage: + """Tests for generate_team_leader_system_message function.""" + + def test_basic_generation(self): + """Test basic system message generation.""" + message = generate_team_leader_system_message( + team_name="test_team", + team_instruction="You are a helpful team", + members=[{ + "name": "researcher", + "description": "Does research" + }], + ) + + assert "test_team" in message + assert "You are a helpful team" in message + assert "researcher" in message + assert "Does research" in message + + def test_team_name_in_message(self): + """Test that team name appears in leader role statement.""" + message = generate_team_leader_system_message( + team_name="content_team", + team_instruction="", + members=[], + ) + + assert "leader of team 'content_team'" in message + + def test_empty_instruction(self): + """Test generation with empty instruction.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "member", + "description": "desc" + }], + ) + + # Should still work without instruction + assert "team" in message + assert "member" in message + + def test_multiple_members(self): + """Test generation with multiple members.""" + members = [ + { + "name": "researcher", + "description": "Researches topics" + }, + { + "name": "writer", + "description": "Writes content" + }, + { + "name": "editor", + "description": "Edits documents" + }, + ] + message = generate_team_leader_system_message( + team_name="content_team", + team_instruction="Create content", + members=members, + ) + + for member in members: + assert member["name"] in message + assert member["description"] in message + + def test_member_numbering(self): + """Test that members are numbered.""" + members = [ + { + "name": "first", + "description": "desc1" + }, + { + "name": "second", + "description": "desc2" + }, + ] + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=members, + ) + + assert "1. first" in message + assert "2. second" in message + + def test_delegation_instructions_included(self): + """Test that delegation instructions are included.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "member", + "description": "desc" + }], + ) + + assert "delegate_to_member" in message + assert "Delegation Instructions" in message + + def test_team_members_section(self): + """Test that Team Members section is included.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "member", + "description": "desc" + }], + ) + + assert "## Team Members" in message + + def test_empty_members_list(self): + """Test generation with no members.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="Do stuff", + members=[], + ) + + # Should still generate basic message + assert "team" in message + assert "Team Members" in message + + def test_member_missing_description(self): + """Test member with missing description uses default.""" + members = [{"name": "researcher"}] # No description + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=members, + ) + + assert "researcher" in message + assert "No description provided" in message + + def test_member_missing_name(self): + """Test member with missing name uses default.""" + members = [{"description": "Does research"}] # No name + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=members, + ) + + # Should use default name + assert "member_1" in message + assert "Does research" in message + + +class TestGetMemberInfoList: + """Tests for get_member_info_list function.""" + + def test_extract_basic_info(self): + """Test extracting basic member information.""" + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = "Researches topics" + mock_member.tools = None + + result = get_member_info_list([mock_member]) + + assert len(result) == 1 + assert result[0]["name"] == "researcher" + assert result[0]["description"] == "Researches topics" + + def test_extract_multiple_members(self): + """Test extracting info from multiple members.""" + member1 = Mock() + member1.name = "researcher" + member1.description = "Researches" + member1.tools = None + + member2 = Mock() + member2.name = "writer" + member2.description = "Writes" + member2.tools = None + + result = get_member_info_list([member1, member2]) + + assert len(result) == 2 + assert result[0]["name"] == "researcher" + assert result[1]["name"] == "writer" + + def test_missing_description(self): + """Test member without description attribute.""" + mock_member = Mock(spec=["name"]) # Only name attribute + mock_member.name = "researcher" + + result = get_member_info_list([mock_member]) + + assert result[0]["description"] == "No description" + + def test_empty_description(self): + """Test member with empty description.""" + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = "" + mock_member.tools = None + + result = get_member_info_list([mock_member]) + + assert result[0]["description"] == "No description" + + def test_none_description(self): + """Test member with None description.""" + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = None + mock_member.tools = None + + result = get_member_info_list([mock_member]) + + assert result[0]["description"] == "No description" + + def test_extract_tool_names(self): + """Test extracting tool names from member.""" + mock_tool1 = Mock() + mock_tool1.name = "search_tool" + + mock_tool2 = Mock() + mock_tool2.name = "calculate_tool" + + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = "Researches" + mock_member.tools = [mock_tool1, mock_tool2] + + result = get_member_info_list([mock_member]) + + assert "tools" in result[0] + assert "search_tool" in result[0]["tools"] + assert "calculate_tool" in result[0]["tools"] + + def test_no_tools(self): + """Test member without tools.""" + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = "Researches" + mock_member.tools = [] + + result = get_member_info_list([mock_member]) + + # Should not have tools key when empty + assert "tools" not in result[0] + + def test_tool_without_name_attribute(self): + """Test tool that doesn't have name attribute.""" + mock_tool = Mock(spec=[]) # No name attribute + + mock_member = Mock() + mock_member.name = "researcher" + mock_member.description = "Researches" + mock_member.tools = [mock_tool] + + result = get_member_info_list([mock_member]) + + # Should have empty tools list (tool name couldn't be extracted) + assert result[0].get("tools", []) == [] + + def test_empty_member_list(self): + """Test with empty member list.""" + result = get_member_info_list([]) + assert result == [] + + def test_preserves_order(self): + """Test that member order is preserved.""" + members = [] + for i in range(5): + m = Mock() + m.name = f"member_{i}" + m.description = f"desc_{i}" + m.tools = None + members.append(m) + + result = get_member_info_list(members) + + for i in range(5): + assert result[i]["name"] == f"member_{i}" + + +class TestSystemMessageContent: + """Tests for specific content in system messages.""" + + def test_coordinator_role_description(self): + """Test that coordinator role is described.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "m", + "description": "d" + }], + ) + + assert "coordinator" in message.lower() + + def test_synthesize_instruction(self): + """Test that instruction to synthesize responses is included.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "m", + "description": "d" + }], + ) + + assert "synthesize" in message.lower() or "final response" in message.lower() + + def test_tips_section(self): + """Test that tips section is included.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "m", + "description": "d" + }], + ) + + assert "Tips" in message or "tips" in message.lower() + + def test_analyze_request_instruction(self): + """Test that instruction to analyze request is included.""" + message = generate_team_leader_system_message( + team_name="team", + team_instruction="", + members=[{ + "name": "m", + "description": "d" + }], + ) + + assert "analyze" in message.lower() diff --git a/tests/teams/test_team_agent.py b/tests/teams/test_team_agent.py new file mode 100644 index 000000000..f3e36b8e1 --- /dev/null +++ b/tests/teams/test_team_agent.py @@ -0,0 +1,2481 @@ +# 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 TeamAgent.""" + +import asyncio +import inspect +from typing import AsyncGenerator +from typing import List +from unittest.mock import AsyncMock +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.exceptions import RunCancelledException +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.models import ModelRegistry +from trpc_agent_sdk.skills import BaseSkillRepository +from trpc_agent_sdk.teams import TeamAgent +from trpc_agent_sdk.teams.core import DELEGATION_SIGNAL_MARKER +from trpc_agent_sdk.teams.core import DelegationSignal +from trpc_agent_sdk.teams.core import DELEGATE_TOOL_NAME +from trpc_agent_sdk.teams.core import TEAM_STATE_KEY +from trpc_agent_sdk.teams.core import TeamRunContext +from trpc_agent_sdk.teams.core import TeamMessageBuilder +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + + +# Test model implementation that can be registered +class MockLLMModel(LLMModel): + """Mock LLM model for unit tests.""" + + @classmethod + def supported_models(cls) -> List[str]: + return [r"test-.*"] + + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Test implementation.""" + yield LlmResponse(content=None) + + def validate_request(self, request: LlmRequest) -> None: + """Test validation.""" + pass + + +@pytest.fixture(scope="module", autouse=True) +def register_test_model(): + """Register test model for all tests in this module.""" + # Save original registry + original_registry = ModelRegistry._registry.copy() + + # Register test model + ModelRegistry.register(MockLLMModel) + + yield + + # Restore original registry + ModelRegistry._registry = original_registry + + +@pytest.fixture +def mock_session(): + """Create a mock session.""" + session = Mock() + session.id = "session-123" + session.app_name = "test_app" + session.user_id = "user-123" + session.state = {} + session.events = [] + return session + + +@pytest.fixture +def mock_session_service(): + """Create a mock session service.""" + service = AsyncMock() + service.get_session = AsyncMock() + service.create_session = AsyncMock() + service.append_event = AsyncMock() + service.get_session_summary = AsyncMock(return_value="") + return service + + +@pytest.fixture +def mock_invocation_context(mock_session, mock_session_service): + """Create a mock invocation context.""" + ctx = Mock(spec=InvocationContext) + ctx.invocation_id = "inv-123" + ctx.session = mock_session + ctx.session_service = mock_session_service + ctx.branch = "team_agent" + ctx.user_content = Content(role="user", parts=[Part.from_text(text="Hello")]) + ctx.override_messages = None + + # Make model_copy return a new mock with updated attributes + def model_copy_side_effect(update=None): + new_ctx = Mock(spec=InvocationContext) + new_ctx.invocation_id = ctx.invocation_id + new_ctx.session = ctx.session + new_ctx.session_service = ctx.session_service + new_ctx.branch = ctx.branch + new_ctx.user_content = ctx.user_content + if update: + for key, value in update.items(): + setattr(new_ctx, key, value) + new_ctx.model_copy = model_copy_side_effect + return new_ctx + + ctx.model_copy = model_copy_side_effect + return ctx + + +@pytest.fixture +def mock_member_agents(): + """Create mock member agents.""" + researcher = Mock(spec=LlmAgent) + researcher.name = "researcher" + researcher.description = "Researches information" + researcher.model = MockLLMModel(model_name="test-model") + researcher.tools = [] + + writer = Mock(spec=LlmAgent) + writer.name = "writer" + writer.description = "Writes content" + writer.model = MockLLMModel(model_name="test-model") + writer.tools = [] + + return [researcher, writer] + + +class TestTeamAgentInit: + """Tests for TeamAgent initialization.""" + + def test_basic_init(self, mock_member_agents): + """Test basic TeamAgent initialization.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team.name == "test_team" + assert len(team.members) == 2 + + def test_default_values(self, mock_member_agents): + """Test TeamAgent default values.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team.parallel_execution is False + assert team.share_team_history is False + assert team.share_member_interactions is False + assert team.num_member_history_runs == 0 + assert team.add_history_to_leader is True + assert team.max_iterations == 20 + + def test_custom_configuration(self, mock_member_agents): + """Test TeamAgent with custom configuration.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + parallel_execution=True, + share_team_history=True, + share_member_interactions=True, + num_member_history_runs=2, + max_iterations=10, + num_history_runs=5, + ) + + assert team.parallel_execution is True + assert team.share_team_history is True + assert team.share_member_interactions is True + assert team.num_member_history_runs == 2 + assert team.max_iterations == 10 + assert team.num_history_runs == 5 + + def test_model_inherited_to_members(self): + """Test that model is NOT inherited to members (they keep their own or empty).""" + member = Mock(spec=LlmAgent) + member.name = "member" + member.model = "" # No model + member.tools = [] + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=[member], + ) + + # Member keeps empty model - TeamAgent doesn't modify member models + assert member.model == "" + + def test_model_not_overwritten_if_set(self): + """Test that member's model is not overwritten if already set.""" + existing_model = MockLLMModel(model_name="test-model") + member = Mock(spec=LlmAgent) + member.name = "member" + member.model = existing_model # Already has model + member.tools = [] + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=[member], + ) + + # Member should keep its own model + assert member.model == existing_model + + def test_leader_agent_initialized(self, mock_member_agents): + """Test that internal leader agent is initialized.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team._leader_agent is not None + assert isinstance(team._leader_agent, LlmAgent) + + def test_leader_agent_has_delegation_tool(self, mock_member_agents): + """Test that leader agent has delegation tool.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Check that leader has tools + assert team._leader_agent.tools is not None + assert len(team._leader_agent.tools) >= 1 + + +class TestTeamAgentFindMember: + """Tests for finding member agents.""" + + def test_find_existing_member(self, mock_member_agents): + """Test finding an existing member by name.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + member = team._find_member_by_name("researcher") + assert member is not None + assert member.name == "researcher" + + def test_find_nonexistent_member(self, mock_member_agents): + """Test finding a non-existent member returns None.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + member = team._find_member_by_name("nonexistent") + assert member is None + + +class TestExtractDelegationSignals: + """Tests for extracting delegation signals from events.""" + + def test_extract_delegation_signal(self, mock_member_agents): + """Test extracting delegation signal from event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Create event with delegation signal in function response + signal = DelegationSignal( + member_name="researcher", + task="Find information", + ) + function_response = FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal}, + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + + assert len(signals) == 1 + assert signals[0].member_name == "researcher" + assert signals[0].task == "Find information" + + def test_extract_multiple_delegation_signals(self, mock_member_agents): + """Test extracting multiple delegation signals from event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Create event with multiple delegation signals + signal1 = DelegationSignal(member_name="researcher", task="Task 1") + signal2 = DelegationSignal(member_name="writer", task="Task 2") + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[ + Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal1}, + id="func-1", + )), + Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal2}, + id="func-2", + )), + ], + ), + ) + + signals = team._extract_delegation_signals(event) + + assert len(signals) == 2 + + def test_no_delegation_signal(self, mock_member_agents): + """Test event without delegation signal.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Create event without delegation signal + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part.from_text(text="Just a response")], + ), + ) + + signals = team._extract_delegation_signals(event) + assert len(signals) == 0 + + def test_extract_signal_from_dict_response(self, mock_member_agents): + """Test extracting signal from dict response (serialized signal).""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Create event with dict response containing signal marker + signal_dict = { + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "writer", + "task": "Write article", + } + function_response = FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal_dict}, + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + + assert len(signals) == 1 + assert signals[0].member_name == "writer" + + def test_extract_signal_from_json_string_response(self, mock_member_agents): + """Test extracting signal from JSON string response. + + This tests the case where FunctionTool serializes a Pydantic model + via model_dump_json(), resulting in a JSON string wrapped as + {"result": ""}. + """ + import json + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Create event with JSON string response (simulating model_dump_json() output) + signal_json = json.dumps({ + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "researcher", + "task": "Research topic", + }) + function_response = FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal_json}, # JSON string, not dict + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + + assert len(signals) == 1 + assert signals[0].member_name == "researcher" + assert signals[0].task == "Research topic" + assert signals[0].marker == DELEGATION_SIGNAL_MARKER + + +class TestExtractTextFromEvent: + """Tests for extracting text from events.""" + + def test_extract_text_from_text_part(self, mock_member_agents): + """Test extracting text from text parts.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part.from_text(text="Hello world")], + ), + ) + + text = team._extract_text_from_event(event) + assert text == "Hello world" + + def test_skips_thought_content(self, mock_member_agents): + """Test that thought content is skipped.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[ + Part(text="Visible", thought=False), + Part(text="Hidden", thought=True), + ], + ), + ) + + text = team._extract_text_from_event(event) + assert "Visible" in text + assert "Hidden" not in text + + def test_skips_delegation_tool_calls(self, mock_member_agents): + """Test that delegation tool calls are skipped.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[ + Part.from_text(text="Response"), + Part(function_call=FunctionCall( + name=DELEGATE_TOOL_NAME, + args={"member_name": "researcher"}, + id="func-1", + )), + ], + ), + ) + + text = team._extract_text_from_event(event) + assert "Response" in text + assert DELEGATE_TOOL_NAME not in text + + +class TestExtractTextFromContent: + """Tests for extracting text from content.""" + + def test_extract_text_from_content(self, mock_member_agents): + """Test basic text extraction from content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + content = Content( + role="user", + parts=[Part.from_text(text="User message")], + ) + + text = team._extract_text_from_content(content) + assert text == "User message" + + def test_extract_skips_thoughts(self, mock_member_agents): + """Test that thoughts are skipped.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + content = Content( + role="user", + parts=[ + Part(text="Visible", thought=False), + Part(text="Thought", thought=True), + ], + ) + + text = team._extract_text_from_content(content) + assert "Visible" in text + assert "Thought" not in text + + def test_extract_empty_content(self, mock_member_agents): + """Test extracting from empty content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team._extract_text_from_content(None) == "" + assert team._extract_text_from_content(Content(role="user", parts=[])) == "" + + +class TestHasNonDelegationToolCalls: + """Tests for detecting non-delegation tool calls.""" + + def test_no_tool_calls(self, mock_member_agents): + """Test event without tool calls.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part.from_text(text="Response")], + ), + ) + + assert team._has_non_delegation_tool_calls(event) is False + + def test_only_delegation_tool_call(self, mock_member_agents): + """Test event with only delegation tool call.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_call=FunctionCall( + name=DELEGATE_TOOL_NAME, + args={}, + id="func-1", + ))], + ), + ) + + assert team._has_non_delegation_tool_calls(event) is False + + def test_custom_tool_call(self, mock_member_agents): + """Test event with custom (non-delegation) tool call.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_call=FunctionCall( + name="custom_tool", + args={}, + id="func-1", + ))], + ), + ) + + assert team._has_non_delegation_tool_calls(event) is True + + +class TestCreateStateUpdateEvent: + """Tests for creating state update events.""" + + def test_create_state_update_event(self, mock_member_agents, mock_invocation_context): + """Test creating state update event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + ctx = TeamRunContext(team_name="test_team") + ctx.add_leader_message("user", "Hello") + + event = team._create_state_update_event(mock_invocation_context, ctx) + + assert event.author == "test_team" + assert event.partial is False + assert TEAM_STATE_KEY in event.actions.state_delta + + def test_state_delta_contains_context(self, mock_member_agents, mock_invocation_context): + """Test that state delta contains full context.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + ctx = TeamRunContext(team_name="test_team") + ctx.add_leader_message("user", "Message") + ctx.add_interaction("researcher", "Task", "Response") + + event = team._create_state_update_event(mock_invocation_context, ctx) + + state = event.actions.state_delta[TEAM_STATE_KEY] + assert len(state["leader_history"]) == 1 + assert len(state["interactions"]) == 1 + + +class TestHITLHelpers: + """Tests for Human-in-the-loop helper methods.""" + + def test_extract_function_response_from_content(self, mock_member_agents): + """Test extracting function response from content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name="test_tool", + response={"result": "success"}, + id="func-123", + ) + content = Content( + role="user", + parts=[Part(function_response=function_response)], + ) + + result = team._extract_function_response_from_content(content) + + assert result is not None + assert result.id == "func-123" + assert result.name == "test_tool" + + def test_extract_function_response_none_content(self, mock_member_agents): + """Test extraction with None content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team._extract_function_response_from_content(None) is None + + def test_extract_function_response_no_parts(self, mock_member_agents): + """Test extraction with empty parts.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + content = Content(role="user", parts=[]) + assert team._extract_function_response_from_content(content) is None + + def test_extract_text_from_function_response_dict(self, mock_member_agents): + """Test extracting text from dict function response.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name="approval_tool", + response={ + "approved": True, + "reason": "Looks good" + }, + id="func-123", + ) + + text = team._extract_text_from_function_response(function_response) + + assert "approval_tool" in text + assert "approved" in text + assert "reason" in text + + def test_extract_text_from_function_response_simple_dict(self, mock_member_agents): + """Test extracting text from simple dict function response.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name="input_tool", + response={"input": "User input here"}, + id="func-123", + ) + + text = team._extract_text_from_function_response(function_response) + + assert "input_tool" in text + assert "User input here" in text + + +class TestMemberMessageFilter: + """Tests for member message filter functionality.""" + + @pytest.mark.asyncio + async def test_apply_default_filter(self, mock_member_agents): + """Test applying default message filter.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + contents = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[Part.from_text(text="Second")]), + ] + + result = await team._apply_member_message_filter("researcher", contents) + + # Default filter should keep all messages + assert "First" in result + assert "Second" in result + + @pytest.mark.asyncio + async def test_apply_custom_single_filter(self, mock_member_agents): + """Test applying single custom filter for all members.""" + from trpc_agent_sdk.teams.core import keep_last_member_message + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + member_message_filter=keep_last_member_message, + ) + + contents = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[Part.from_text(text="Last")]), + ] + + result = await team._apply_member_message_filter("researcher", contents) + + # Last filter should only keep last message + assert result == "Last" + assert "First" not in result + + @pytest.mark.asyncio + async def test_apply_per_member_filters(self, mock_member_agents): + """Test applying per-member filters.""" + from trpc_agent_sdk.teams.core import keep_all_member_message, keep_last_member_message + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + member_message_filter={ + "researcher": keep_all_member_message, + "writer": keep_last_member_message, + }, + ) + + contents = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[Part.from_text(text="Last")]), + ] + + researcher_result = await team._apply_member_message_filter("researcher", contents) + writer_result = await team._apply_member_message_filter("writer", contents) + + # Researcher should have all messages + assert "First" in researcher_result + assert "Last" in researcher_result + + # Writer should only have last + assert writer_result == "Last" + assert "First" not in writer_result + + @pytest.mark.asyncio + async def test_per_member_filter_fallback_to_default(self, mock_member_agents): + """Test per-member filter falls back to default for unconfigured member.""" + from trpc_agent_sdk.teams.core import keep_last_member_message + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + member_message_filter={ + "researcher": keep_last_member_message, + # writer not configured - should use default + }, + ) + + contents = [ + Content(role="model", parts=[Part.from_text(text="First")]), + Content(role="model", parts=[Part.from_text(text="Last")]), + ] + + writer_result = await team._apply_member_message_filter("writer", contents) + + # Writer should use default (keep_all) since not in filter dict + assert "First" in writer_result + assert "Last" in writer_result + + +class TestTeamAgentWithInstruction: + """Tests for TeamAgent instruction handling.""" + + def test_instruction_passed_to_leader(self, mock_member_agents): + """Test that team instruction is passed to leader agent.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + instruction="You are a helpful team coordinator", + ) + + # Leader agent instruction should contain team instruction + assert "helpful team coordinator" in team._leader_agent.instruction + + def test_instruction_includes_members(self, mock_member_agents): + """Test that leader instruction includes member information.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Leader instruction should mention members + assert "researcher" in team._leader_agent.instruction + assert "writer" in team._leader_agent.instruction + + def test_dynamic_instruction_callable(self, mock_member_agents): + """Test that callable instruction creates dynamic provider.""" + + def my_instruction(ctx): + return "Dynamic instruction" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + instruction=my_instruction, + ) + + assert callable(team._leader_agent.instruction) + + @pytest.mark.asyncio + async def test_resolve_dynamic_leader_instruction_sync(self, mock_member_agents): + """Test resolving sync callable instruction at runtime.""" + + def sync_instruction(ctx): + return "Sync dynamic instruction" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + instruction=sync_instruction, + ) + + ctx = Mock(spec=InvocationContext) + result = await team._resolve_dynamic_leader_instruction(ctx) + + assert "Sync dynamic instruction" in result + assert "test_team" in result + + @pytest.mark.asyncio + async def test_resolve_dynamic_leader_instruction_async(self, mock_member_agents): + """Test resolving async callable instruction at runtime.""" + + async def async_instruction(ctx): + return "Async dynamic instruction" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + instruction=async_instruction, + ) + + ctx = Mock(spec=InvocationContext) + result = await team._resolve_dynamic_leader_instruction(ctx) + + assert "Async dynamic instruction" in result + assert "test_team" in result + + +class TestTeamAgentWithTools: + """Tests for TeamAgent custom tools handling.""" + + def test_custom_tools_added_to_leader(self, mock_member_agents): + """Test that custom tools are added to leader agent.""" + from trpc_agent_sdk.tools import FunctionTool + + def custom_calculator(a: int, b: int) -> int: + """A custom calculator tool.""" + return a + b + + custom_tool = FunctionTool(func=custom_calculator) + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[custom_tool], + ) + + # Leader should have delegation tool + custom tool + assert len(team._leader_agent.tools) >= 2 + + def test_long_running_tool_tracking(self, mock_member_agents): + """Test that long running tools are tracked.""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_function(data: str) -> str: + """An approval tool.""" + return "approved" + + long_running_tool = LongRunningFunctionTool(func=approval_function) + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[long_running_tool], + ) + + # Long running tool should be tracked (name is derived from function name) + assert "approval_function" in team._long_running_tool_names + + def test_skill_repository_passed_to_leader(self, mock_member_agents): + """Test that TeamAgent skill_repository is propagated to internal leader agent.""" + skill_repository = Mock(spec=BaseSkillRepository) + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + skill_repository=skill_repository, + ) + + assert team.skill_repository is skill_repository + assert team._leader_agent.skill_repository is skill_repository + + def test_skill_repository_default_none_on_leader(self, mock_member_agents): + """Test that leader skill_repository defaults to None when not configured.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team.skill_repository is None + assert team._leader_agent.skill_repository is None + + +class TestParallelExecutionWithLock: + """Tests for parallel execution with context_lock.""" + + @pytest.mark.asyncio + async def test_parallel_delegations_record_all_interactions(self, mock_member_agents, mock_invocation_context): + """Test that parallel delegations correctly record all interactions.""" + import asyncio + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + parallel_execution=True, + ) + + # Mock both member agents + async def mock_researcher_run(ctx): + yield Event( + invocation_id="inv-123", + author="researcher", + content=Content(role="model", parts=[Part.from_text(text="Research done")]), + partial=False, + ) + + async def mock_writer_run(ctx): + yield Event( + invocation_id="inv-123", + author="writer", + content=Content(role="model", parts=[Part.from_text(text="Writing done")]), + partial=False, + ) + + mock_member_agents[0].run_async = mock_researcher_run + mock_member_agents[1].run_async = mock_writer_run + + # Create context with lock + team_run_context = TeamRunContext(team_name="test_team") + team_run_context.current_invocation_id = "inv-123" + context_lock = asyncio.Lock() + + from trpc_agent_sdk.teams.core._message_builder import TeamMessageBuilder + message_builder = TeamMessageBuilder() + + signals = [ + DelegationSignal(member_name="researcher", task="Research task"), + DelegationSignal(member_name="writer", task="Writing task"), + ] + + # Execute parallel delegations + events = [] + async for event in team._execute_delegations_parallel( + mock_invocation_context, + signals, + team_run_context, + message_builder, + is_member_mode=False, + context_lock=context_lock, + ): + events.append(event) + + # Both interactions should be recorded + assert len(team_run_context.interactions) == 2 + member_names = {i["member"] for i in team_run_context.interactions} + assert "researcher" in member_names + assert "writer" in member_names + + +class TestMemberModeHITLRestriction: + """Tests for HITL restriction when TeamAgent runs as member.""" + + @pytest.mark.asyncio + async def test_member_hitl_raises_error_in_execute_delegation(self, mock_member_agents, mock_invocation_context): + """Test that HITL from member raises RuntimeError in member mode.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Mock member to yield LongRunningEvent + member = mock_member_agents[0] + + async def mock_run_with_hitl(ctx): + yield LongRunningEvent( + invocation_id="inv-123", + author="researcher", + function_call=FunctionCall(name="approval_tool", args={}, id="func-123"), + function_response=FunctionResponse(name="approval_tool", response={}, id="func-123"), + ) + + member.run_async = mock_run_with_hitl + + team_run_context = TeamRunContext(team_name="test_team") + team_run_context.current_invocation_id = "inv-123" + + from trpc_agent_sdk.teams.core._message_builder import TeamMessageBuilder + message_builder = TeamMessageBuilder() + + signal = DelegationSignal(member_name="researcher", task="Task requiring approval") + + # Should raise RuntimeError when in member mode + with pytest.raises(RuntimeError) as exc_info: + async for _ in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=True, # Running as member + context_lock=None, + ): + pass + + assert "member mode" in str(exc_info.value).lower() + assert "Human-In-The-Loop" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_member_hitl_allowed_in_root_mode(self, mock_member_agents, mock_invocation_context): + """Test that HITL from member is allowed when NOT in member mode.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + # Mock member to yield LongRunningEvent + member = mock_member_agents[0] + hitl_event = LongRunningEvent( + invocation_id="inv-123", + author="researcher", + function_call=FunctionCall(name="approval_tool", args={}, id="func-123"), + function_response=FunctionResponse(name="approval_tool", response={}, id="func-123"), + ) + + async def mock_run_with_hitl(ctx): + yield hitl_event + + member.run_async = mock_run_with_hitl + + team_run_context = TeamRunContext(team_name="test_team") + team_run_context.current_invocation_id = "inv-123" + + from trpc_agent_sdk.teams.core._message_builder import TeamMessageBuilder + message_builder = TeamMessageBuilder() + + signal = DelegationSignal(member_name="researcher", task="Task requiring approval") + + # Should NOT raise error when NOT in member mode + events = [] + async for event in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=False, # NOT running as member (root mode) + context_lock=None, + ): + events.append(event) + + # HITL event should be yielded + assert len(events) == 1 + assert isinstance(events[0], LongRunningEvent) + + +class TestExtractTextFromOverrideMessages: + """Tests for _extract_text_from_override_messages.""" + + def test_empty_override_messages(self, mock_member_agents): + """Test extraction from empty override messages.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team._extract_text_from_override_messages([]) == "" + assert team._extract_text_from_override_messages(None) == "" + + def test_single_override_message(self, mock_member_agents): + """Test extraction from single override message.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + messages = [Content(role="user", parts=[Part.from_text(text="Do the task")])] + result = team._extract_text_from_override_messages(messages) + assert result == "Do the task" + + def test_multiple_override_messages(self, mock_member_agents): + """Test extraction from multiple override messages.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + messages = [ + Content(role="user", parts=[Part.from_text(text="Context info")]), + Content(role="user", parts=[Part.from_text(text="Actual task")]), + ] + result = team._extract_text_from_override_messages(messages) + assert "Context info" in result + assert "Actual task" in result + + def test_override_messages_skips_empty_content(self, mock_member_agents): + """Test that empty content in override messages is skipped.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + messages = [ + Content(role="user", parts=[]), + Content(role="user", parts=[Part.from_text(text="Real content")]), + ] + result = team._extract_text_from_override_messages(messages) + assert result == "Real content" + + +class TestExtractTextFromEventExtended: + """Extended tests for _extract_text_from_event edge cases.""" + + def test_extract_text_from_none_event(self, mock_member_agents): + """Test extraction from None event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + assert team._extract_text_from_event(None) == "" + + def test_extract_text_with_no_content(self, mock_member_agents): + """Test extraction from event with no content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event(invocation_id="inv-123", author="test", content=None) + assert team._extract_text_from_event(event) == "" + + def test_extract_includes_custom_function_call(self, mock_member_agents): + """Test that custom (non-delegation) function calls are included as text.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_call=FunctionCall( + name="search_tool", args={"q": "test"}, id="fc-1", + ))], + ), + ) + text = team._extract_text_from_event(event) + assert "[Tool Call: search_tool" in text + + def test_extract_includes_custom_function_response(self, mock_member_agents): + """Test that custom (non-delegation) function responses are included.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name="search_tool", response={"result": "found"}, id="fr-1", + ))], + ), + ) + text = team._extract_text_from_event(event) + assert "[Tool Result:" in text + + def test_extract_skips_delegation_function_response(self, mock_member_agents): + """Test that delegation function responses are skipped.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, response={}, id="fr-1", + ))], + ), + ) + text = team._extract_text_from_event(event) + assert text == "" + + def test_extract_skips_long_running_function_response(self, mock_member_agents): + """Test that long-running tool function responses are skipped.""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_function(data: str) -> str: + """Approval tool.""" + return "approved" + + long_tool = LongRunningFunctionTool(func=approval_function) + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[long_tool], + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name="approval_function", response={}, id="fr-1", + ))], + ), + ) + text = team._extract_text_from_event(event) + assert text == "" + + def test_extract_keeps_long_running_function_call_as_text(self, mock_member_agents): + """Test that long-running tool function calls are kept as text.""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_function(data: str) -> str: + """Approval tool.""" + return "approved" + + long_tool = LongRunningFunctionTool(func=approval_function) + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[long_tool], + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_call=FunctionCall( + name="approval_function", args={"data": "test"}, id="fc-1", + ))], + ), + ) + text = team._extract_text_from_event(event) + assert "[Tool Call: approval_function" in text + + +class TestHasNonDelegationToolCallsExtended: + """Extended tests for _has_non_delegation_tool_calls.""" + + def test_none_event(self, mock_member_agents): + """Test with None event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + assert team._has_non_delegation_tool_calls(None) is False + + def test_event_with_no_content(self, mock_member_agents): + """Test event with no content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event(invocation_id="inv-123", author="test", content=None) + assert team._has_non_delegation_tool_calls(event) is False + + def test_event_with_empty_parts(self, mock_member_agents): + """Test event with empty parts.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content(role="model", parts=[]), + ) + assert team._has_non_delegation_tool_calls(event) is False + + def test_custom_function_response_detected(self, mock_member_agents): + """Test that custom function response is detected.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name="custom_tool", response={}, id="fr-1", + ))], + ), + ) + assert team._has_non_delegation_tool_calls(event) is True + + def test_delegation_function_response_not_detected(self, mock_member_agents): + """Test that delegation function response is NOT detected as custom tool.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, response={}, id="fr-1", + ))], + ), + ) + assert team._has_non_delegation_tool_calls(event) is False + + def test_long_running_tool_call_not_detected(self, mock_member_agents): + """Test that long-running tool call is skipped (not treated as custom tool).""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_func(data: str) -> str: + """Approval.""" + return "ok" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[LongRunningFunctionTool(func=approval_func)], + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_call=FunctionCall( + name="approval_func", args={}, id="fc-1", + ))], + ), + ) + assert team._has_non_delegation_tool_calls(event) is False + + def test_long_running_function_response_not_detected(self, mock_member_agents): + """Test that long-running function response is skipped.""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_func(data: str) -> str: + """Approval.""" + return "ok" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[LongRunningFunctionTool(func=approval_func)], + ) + event = Event( + invocation_id="inv-123", + author="test", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name="approval_func", response={}, id="fr-1", + ))], + ), + ) + assert team._has_non_delegation_tool_calls(event) is False + + +class TestHITLHelpersExtended: + """Extended tests for HITL helper methods.""" + + def test_extract_function_response_with_text_only_content(self, mock_member_agents): + """Test extraction from content with only text parts.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + content = Content(role="user", parts=[Part.from_text(text="Just text")]) + assert team._extract_function_response_from_content(content) is None + + def test_extract_text_from_function_response_non_dict(self, mock_member_agents): + """Test extracting text from function response with non-dict response data.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + function_response = Mock() + function_response.name = "input_tool" + function_response.response = "Simple string response" + function_response.id = "func-123" + + text = team._extract_text_from_function_response(function_response) + assert "input_tool" in text + assert "Simple string response" in text + + +class TestTeamAgentSetattr: + """Tests for __setattr__ override syncing parent_agent.""" + + def test_setattr_syncs_parent_agent_to_leader(self, mock_member_agents): + """Test that setting parent_agent syncs to leader_agent.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_parent = Mock(spec=BaseAgent) + mock_parent.name = "parent_agent" + team.parent_agent = mock_parent + + assert team._leader_agent.parent_agent is mock_parent + + +class TestSyncLeaderTransferHierarchy: + """Tests for _sync_leader_transfer_hierarchy.""" + + def test_sync_transfer_flags(self, mock_member_agents): + """Test that leader transfer flags are synced from TeamAgent.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + disallow_transfer_to_parent=True, + disallow_transfer_to_peers=True, + ) + + assert team._leader_agent.disallow_transfer_to_parent is True + assert team._leader_agent.disallow_transfer_to_peers is True + + def test_sync_transfer_flags_default(self, mock_member_agents): + """Test default transfer flags sync.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + assert team._leader_agent.disallow_transfer_to_parent == team.disallow_transfer_to_parent + assert team._leader_agent.disallow_transfer_to_peers == team.disallow_transfer_to_peers + + +class TestExtractDelegationSignalsExtended: + """Extended tests for _extract_delegation_signals edge cases.""" + + def test_extract_from_top_level_dict_signal(self, mock_member_agents): + """Test extracting delegation signal from top-level response data.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={ + "marker": DELEGATION_SIGNAL_MARKER, + "action": "delegate_to_member", + "member_name": "researcher", + "task": "Do research", + }, + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + assert len(signals) == 1 + assert signals[0].member_name == "researcher" + + def test_extract_from_response_without_result_key(self, mock_member_agents): + """Test with function response dict that has no 'result' key or marker.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name="some_tool", + response={"output": "plain response"}, + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + assert len(signals) == 0 + + def test_extract_handles_none_result_in_dict(self, mock_member_agents): + """Test with dict response where result is None.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + function_response = FunctionResponse( + name="some_tool", + response={"result": None}, + id="func-123", + ) + event = Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=function_response)], + ), + ) + + signals = team._extract_delegation_signals(event) + assert len(signals) == 0 + + +class TestExecuteDelegationMemberNotFound: + """Tests for _execute_delegation when member is not found.""" + + @pytest.mark.asyncio + async def test_member_not_found_yields_error(self, mock_member_agents, mock_invocation_context): + """Test that delegation to non-existent member yields error event.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + team_run_context = TeamRunContext(team_name="test_team", current_invocation_id="inv-123") + message_builder = TeamMessageBuilder() + signal = DelegationSignal(member_name="nonexistent_member", task="Some task") + + events = [] + async for event in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=False, + context_lock=None, + ): + events.append(event) + + assert len(events) == 1 + assert "nonexistent_member" in events[0].content.parts[0].text + assert "not found" in events[0].content.parts[0].text.lower() + assert len(team_run_context.interactions) == 1 + + +class TestExecuteDelegationCancellation: + """Tests for cancellation handling in _execute_delegation.""" + + @pytest.mark.asyncio + async def test_cancellation_during_member_with_partial_text(self, mock_member_agents, mock_invocation_context): + """Test cancellation during member execution with partial streaming text.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + member = mock_member_agents[0] + + async def mock_run_with_cancel(ctx): + yield Event( + invocation_id="inv-123", + author="researcher", + content=Content(role="model", parts=[Part.from_text(text="Partial")]), + partial=True, + ) + raise RunCancelledException("Cancelled") + + member.run_async = mock_run_with_cancel + + team_run_context = TeamRunContext(team_name="test_team", current_invocation_id="inv-123") + message_builder = TeamMessageBuilder() + signal = DelegationSignal(member_name="researcher", task="Research task") + + with pytest.raises(RunCancelledException): + async for _ in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=False, + context_lock=None, + ): + pass + + assert len(team_run_context.interactions) == 1 + assert "interrupted by cancellation" in team_run_context.interactions[0]["response"] + + @pytest.mark.asyncio + async def test_cancellation_during_member_no_output(self, mock_member_agents, mock_invocation_context): + """Test cancellation during member execution with no output.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + member = mock_member_agents[0] + + async def mock_run_cancel_immediately(ctx): + raise RunCancelledException("Cancelled") + yield # noqa: unreachable - makes it an async generator + + member.run_async = mock_run_cancel_immediately + + team_run_context = TeamRunContext(team_name="test_team", current_invocation_id="inv-123") + message_builder = TeamMessageBuilder() + signal = DelegationSignal(member_name="researcher", task="Research task") + + with pytest.raises(RunCancelledException): + async for _ in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=False, + context_lock=None, + ): + pass + + assert len(team_run_context.interactions) == 1 + assert "before any output" in team_run_context.interactions[0]["response"] + + @pytest.mark.asyncio + async def test_cancellation_with_context_lock(self, mock_member_agents, mock_invocation_context): + """Test cancellation with context_lock (parallel mode).""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + member = mock_member_agents[0] + + async def mock_run_cancel(ctx): + raise RunCancelledException("Cancelled") + yield # noqa + + member.run_async = mock_run_cancel + + team_run_context = TeamRunContext(team_name="test_team", current_invocation_id="inv-123") + message_builder = TeamMessageBuilder() + signal = DelegationSignal(member_name="researcher", task="Research task") + context_lock = asyncio.Lock() + + with pytest.raises(RunCancelledException): + async for _ in team._execute_delegation( + mock_invocation_context, + signal, + team_run_context, + message_builder, + is_member_mode=False, + context_lock=context_lock, + ): + pass + + assert len(team_run_context.interactions) == 1 + + +def _replace_leader(team, run_async_fn): + """Replace team's _leader_agent with a mock that uses the given async generator function.""" + mock_leader = Mock() + mock_leader.run_async = run_async_fn + team.__pydantic_private__['_leader_agent'] = mock_leader + + +class TestRunAsyncImpl: + """Tests for the main _run_async_impl execution loop.""" + + @pytest.mark.asyncio + async def test_root_mode_text_response_no_delegation(self, mock_member_agents, mock_invocation_context): + """Test root mode: leader returns text without delegation.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + text_event = Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Final answer")]), + partial=False, + ) + + async def mock_leader_run(ctx): + yield text_event + + _replace_leader(team, mock_leader_run) + + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + text_events = [e for e in events if e.content and e.content.parts] + assert any("Final answer" in e.content.parts[0].text for e in text_events if e.content.parts[0].text) + + @pytest.mark.asyncio + async def test_root_mode_delegation_then_response(self, mock_member_agents, mock_invocation_context): + """Test root mode: leader delegates then responds.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + call_count = 0 + + async def mock_leader_run(ctx): + nonlocal call_count + call_count += 1 + + if call_count == 1: + signal = DelegationSignal(member_name="researcher", task="Find data") + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal}, + id="func-1", + ))], + ), + partial=False, + ) + else: + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Summary based on research")]), + partial=False, + ) + + researcher = mock_member_agents[0] + + async def mock_researcher_run(ctx): + yield Event( + invocation_id="inv-123", + author="researcher", + content=Content(role="model", parts=[Part.from_text(text="Research results")]), + partial=False, + ) + + researcher.run_async = mock_researcher_run + + _replace_leader(team, mock_leader_run) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert call_count == 2 + text_contents = [ + e.content.parts[0].text + for e in events + if e.content and e.content.parts and e.content.parts[0].text + ] + assert any("Research results" in t for t in text_contents) + assert any("Summary based on research" in t for t in text_contents) + + @pytest.mark.asyncio + async def test_root_mode_no_events_from_leader(self, mock_member_agents, mock_invocation_context): + """Test root mode: leader produces no events.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + async def mock_leader_run_empty(ctx): + return + yield # noqa: makes it an async generator + + _replace_leader(team, mock_leader_run_empty) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + state_events = [e for e in events if e.actions.state_delta] + assert len(state_events) >= 0 + + @pytest.mark.asyncio + async def test_root_mode_max_iterations(self, mock_member_agents, mock_invocation_context): + """Test root mode: max iterations limit reached.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + max_iterations=2, + ) + + signal = DelegationSignal(member_name="researcher", task="Infinite task") + + async def mock_leader_always_delegate(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, + response={"result": signal}, + id="func-1", + ))], + ), + partial=False, + ) + + researcher = mock_member_agents[0] + + async def mock_researcher_run(ctx): + yield Event( + invocation_id="inv-123", + author="researcher", + content=Content(role="model", parts=[Part.from_text(text="Done")]), + partial=False, + ) + + researcher.run_async = mock_researcher_run + + _replace_leader(team, mock_leader_always_delegate) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert len([e for e in events if e.content and e.content.parts and + any(p.text == "Done" for p in e.content.parts if p.text)]) <= 2 + + @pytest.mark.asyncio + async def test_member_mode_with_override_messages(self, mock_member_agents, mock_invocation_context): + """Test member mode: TeamAgent uses override_messages.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_invocation_context.override_messages = [ + Content(role="user", parts=[Part.from_text(text="Task from parent team")]) + ] + + async def mock_leader_run(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Done")]), + partial=False, + ) + + _replace_leader(team, mock_leader_run) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + text_events = [e for e in events if e.content and e.content.parts] + assert len(text_events) >= 1 + + @pytest.mark.asyncio + async def test_root_mode_cancellation_during_leader(self, mock_member_agents, mock_invocation_context): + """Test root mode: cancellation during leader planning.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + async def mock_leader_cancelled(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Partial")]), + partial=True, + ) + raise RunCancelledException("Cancelled") + + _replace_leader(team, mock_leader_cancelled) + events = [] + with pytest.raises(RunCancelledException): + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + @pytest.mark.asyncio + async def test_root_mode_transfer_to_agent(self, mock_member_agents, mock_invocation_context): + """Test root mode: leader requests transfer to another agent.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + transfer_event = Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Transferring")]), + partial=False, + ) + transfer_event.actions.transfer_to_agent = "other_agent" + + async def mock_leader_transfer(ctx): + yield transfer_event + + _replace_leader(team, mock_leader_transfer) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert any(e.actions.transfer_to_agent == "other_agent" for e in events) + + @pytest.mark.asyncio + async def test_root_mode_custom_tool_continues_loop(self, mock_member_agents, mock_invocation_context): + """Test root mode: custom tool execution causes loop continuation.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + call_count = 0 + + async def mock_leader_with_tool(ctx): + nonlocal call_count + call_count += 1 + + if call_count == 1: + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[ + Part.from_text(text="Using tool"), + Part(function_call=FunctionCall( + name="custom_calculator", args={"a": 1, "b": 2}, id="fc-1", + )), + Part(function_response=FunctionResponse( + name="custom_calculator", response={"result": 3}, id="fr-1", + )), + ], + ), + partial=False, + ) + else: + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Final answer: 3")]), + partial=False, + ) + + _replace_leader(team, mock_leader_with_tool) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert call_count == 2 + + @pytest.mark.asyncio + async def test_root_mode_hitl_event(self, mock_member_agents, mock_invocation_context): + """Test root mode: leader triggers LongRunningEvent (HITL).""" + from trpc_agent_sdk.tools import LongRunningFunctionTool + + def approval_func(data: str) -> str: + """Approval tool.""" + return "ok" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + tools=[LongRunningFunctionTool(func=approval_func)], + ) + + hitl_event = LongRunningEvent( + invocation_id="inv-123", + author="test_team_internal", + function_call=FunctionCall(name="approval_func", args={}, id="fc-hitl"), + function_response=FunctionResponse(name="approval_func", response={}, id="fc-hitl"), + ) + + async def mock_leader_hitl(ctx): + yield hitl_event + + _replace_leader(team, mock_leader_hitl) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + hitl_events = [e for e in events if isinstance(e, LongRunningEvent)] + assert len(hitl_events) == 1 + assert hitl_events[0].author == "test_team" + + @pytest.mark.asyncio + async def test_root_mode_hitl_resume(self, mock_member_agents, mock_invocation_context): + """Test root mode: resume from HITL with FunctionResponse in user_content.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_invocation_context.session.state = { + TEAM_STATE_KEY: { + "team_name": "test_team", + "interactions": [], + "leader_history": [{"role": "user", "text": "Previous question", "invocation_id": "inv-old"}], + "current_invocation_id": "inv-old", + "pending_function_call_id": "fc-hitl", + } + } + + mock_invocation_context.user_content = Content( + role="user", + parts=[Part(function_response=FunctionResponse( + name="approval_func", + response={"approved": True}, + id="fc-hitl", + ))], + ) + + async def mock_leader_after_resume(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Resumed and done")]), + partial=False, + ) + + _replace_leader(team, mock_leader_after_resume) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + text_events = [ + e for e in events + if e.content and e.content.parts and any(p.text for p in e.content.parts) + ] + assert len(text_events) >= 1 + + @pytest.mark.asyncio + async def test_member_mode_hitl_raises_error(self, mock_member_agents, mock_invocation_context): + """Test member mode: HITL from leader raises RuntimeError.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_invocation_context.override_messages = [ + Content(role="user", parts=[Part.from_text(text="Task")]) + ] + + hitl_event = LongRunningEvent( + invocation_id="inv-123", + author="test_team", + function_call=FunctionCall(name="approval_func", args={}, id="fc-hitl"), + function_response=FunctionResponse(name="approval_func", response={}, id="fc-hitl"), + ) + + async def mock_leader_hitl(ctx): + yield hitl_event + + _replace_leader(team, mock_leader_hitl) + with pytest.raises(RuntimeError, match="member mode"): + async for _ in team._run_async_impl(mock_invocation_context): + pass + + @pytest.mark.asyncio + async def test_root_mode_partial_events_tracked(self, mock_member_agents, mock_invocation_context): + """Test root mode: partial (streaming) events are yielded and tracked.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + async def mock_leader_with_partial(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Hello ")]), + partial=True, + ) + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Hello World")]), + partial=False, + ) + + _replace_leader(team, mock_leader_with_partial) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + partial_events = [e for e in events if e.partial] + non_partial_events = [e for e in events if not e.partial and e.content and e.content.parts] + assert len(partial_events) >= 1 + assert len(non_partial_events) >= 1 + + @pytest.mark.asyncio + async def test_root_mode_cancellation_with_partial_leader_text(self, mock_member_agents, mock_invocation_context): + """Test root mode: cancellation saves partial leader text to history.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + async def mock_leader_partial_then_cancel(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Thinking about ")]), + partial=True, + ) + raise RunCancelledException("User cancelled") + + _replace_leader(team, mock_leader_partial_then_cancel) + events = [] + with pytest.raises(RunCancelledException): + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + state_events = [e for e in events if e.actions.state_delta] + assert len(state_events) >= 1 + + @pytest.mark.asyncio + async def test_root_mode_parallel_delegations(self, mock_member_agents, mock_invocation_context): + """Test root mode with parallel_execution=True and multiple delegations.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + parallel_execution=True, + ) + + call_count = 0 + + async def mock_leader_multi_delegate(ctx): + nonlocal call_count + call_count += 1 + + if call_count == 1: + signal1 = DelegationSignal(member_name="researcher", task="Research") + signal2 = DelegationSignal(member_name="writer", task="Write") + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[ + Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, response={"result": signal1}, id="f1", + )), + Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, response={"result": signal2}, id="f2", + )), + ], + ), + partial=False, + ) + else: + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="All done")]), + partial=False, + ) + + async def mock_researcher_run(ctx): + yield Event( + invocation_id="inv-123", + author="researcher", + content=Content(role="model", parts=[Part.from_text(text="Research done")]), + partial=False, + ) + + async def mock_writer_run(ctx): + yield Event( + invocation_id="inv-123", + author="writer", + content=Content(role="model", parts=[Part.from_text(text="Writing done")]), + partial=False, + ) + + mock_member_agents[0].run_async = mock_researcher_run + mock_member_agents[1].run_async = mock_writer_run + + _replace_leader(team, mock_leader_multi_delegate) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert call_count == 2 + + @pytest.mark.asyncio + async def test_root_mode_user_content_none(self, mock_member_agents, mock_invocation_context): + """Test root mode: no user_content provided.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_invocation_context.user_content = None + mock_invocation_context.override_messages = None + + async def mock_leader_run(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Response")]), + partial=False, + ) + + _replace_leader(team, mock_leader_run) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + assert len(events) >= 1 + + @pytest.mark.asyncio + async def test_root_mode_cancellation_during_delegation(self, mock_member_agents, mock_invocation_context): + """Test root mode: cancellation during member delegation re-raises.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + signal = DelegationSignal(member_name="researcher", task="Research") + + async def mock_leader_delegate(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content( + role="model", + parts=[Part(function_response=FunctionResponse( + name=DELEGATE_TOOL_NAME, response={"result": signal}, id="f1", + ))], + ), + partial=False, + ) + + researcher = mock_member_agents[0] + + async def mock_researcher_cancel(ctx): + raise RunCancelledException("Cancelled") + yield # noqa + + researcher.run_async = mock_researcher_cancel + + _replace_leader(team, mock_leader_delegate) + with pytest.raises(RunCancelledException): + async for _ in team._run_async_impl(mock_invocation_context): + pass + + @pytest.mark.asyncio + async def test_member_mode_no_state_persistence(self, mock_member_agents, mock_invocation_context): + """Test member mode: state update events are NOT emitted.""" + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + ) + + mock_invocation_context.override_messages = [ + Content(role="user", parts=[Part.from_text(text="Do task")]) + ] + + async def mock_leader_run(ctx): + yield Event( + invocation_id="inv-123", + author="test_team", + content=Content(role="model", parts=[Part.from_text(text="Done")]), + partial=False, + ) + + _replace_leader(team, mock_leader_run) + events = [] + async for event in team._run_async_impl(mock_invocation_context): + events.append(event) + + state_events = [e for e in events if e.actions.state_delta] + assert len(state_events) == 0 + + +class TestApplyMemberMessageFilterSync: + """Tests for applying sync member message filter.""" + + @pytest.mark.asyncio + async def test_apply_sync_filter(self, mock_member_agents): + """Test applying a sync (non-async) filter function.""" + + def sync_filter(messages: List[Content]) -> str: + return "sync filtered" + + team = TeamAgent( + name="test_team", + model=MockLLMModel(model_name="test-model"), + members=mock_member_agents, + member_message_filter=sync_filter, + ) + + contents = [Content(role="model", parts=[Part.from_text(text="Test")])] + result = await team._apply_member_message_filter("researcher", contents) + assert result == "sync filtered" diff --git a/tests/teams/test_team_run_context.py b/tests/teams/test_team_run_context.py new file mode 100644 index 000000000..5e267ae5e --- /dev/null +++ b/tests/teams/test_team_run_context.py @@ -0,0 +1,489 @@ +# 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 TeamRunContext.""" + +from trpc_agent_sdk.teams.core import TEAM_STATE_KEY +from trpc_agent_sdk.teams.core import TeamRunContext + + +class TestTeamRunContextBasic: + """Tests for TeamRunContext basic functionality.""" + + def test_init_default_values(self): + """Test TeamRunContext initializes with empty default values.""" + ctx = TeamRunContext() + assert ctx.interactions == [] + assert ctx.team_name == "" + assert ctx.leader_history == [] + assert ctx.current_invocation_id == "" + assert ctx.pending_function_call_id == "" + + def test_init_with_team_name(self): + """Test TeamRunContext initializes with team name.""" + ctx = TeamRunContext(team_name="test_team") + assert ctx.team_name == "test_team" + + +class TestTeamRunContextInteractions: + """Tests for interaction recording functionality.""" + + def test_add_interaction(self): + """Test adding a single member interaction.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_interaction("researcher", "Find information", "Found results") + + assert len(ctx.interactions) == 1 + assert ctx.interactions[0]["member"] == "researcher" + assert ctx.interactions[0]["task"] == "Find information" + assert ctx.interactions[0]["response"] == "Found results" + assert ctx.interactions[0]["invocation_id"] == "inv-123" + + def test_add_multiple_interactions(self): + """Test adding multiple member interactions.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_interaction("researcher", "Task 1", "Response 1") + ctx.add_interaction("writer", "Task 2", "Response 2") + + assert len(ctx.interactions) == 2 + assert ctx.interactions[0]["member"] == "researcher" + assert ctx.interactions[1]["member"] == "writer" + + def test_get_current_run_interactions(self): + """Test filtering interactions by current invocation ID.""" + ctx = TeamRunContext() + + # Add interactions from different invocations + ctx.current_invocation_id = "inv-1" + ctx.add_interaction("researcher", "Task 1", "Response 1") + + ctx.current_invocation_id = "inv-2" + ctx.add_interaction("writer", "Task 2", "Response 2") + ctx.add_interaction("editor", "Task 3", "Response 3") + + # Should only return interactions from current invocation (inv-2) + current = ctx.get_current_run_interactions() + assert len(current) == 2 + assert all(i["invocation_id"] == "inv-2" for i in current) + + def test_get_current_run_interactions_empty_invocation_id(self): + """Test get_current_run_interactions returns all when no invocation_id set.""" + ctx = TeamRunContext() + ctx.add_interaction("researcher", "Task 1", "Response 1") + ctx.add_interaction("writer", "Task 2", "Response 2") + + # Should return all interactions when current_invocation_id is empty + current = ctx.get_current_run_interactions() + assert len(current) == 2 + + def test_get_member_interactions_for_runs_filters_member_and_runs(self): + """Test member interactions are filtered by member name and run count.""" + ctx = TeamRunContext() + + ctx.current_invocation_id = "inv-1" + ctx.add_interaction("researcher", "Old task", "Old response") + ctx.add_interaction("writer", "Old writer task", "Old writer response") + + ctx.current_invocation_id = "inv-2" + ctx.add_interaction("researcher", "New task", "New response") + ctx.add_interaction("writer", "New writer task", "New writer response") + + ctx.current_invocation_id = "inv-3" + ctx.add_interaction("researcher", "Latest task", "Latest response") + + interactions = ctx.get_member_interactions_for_runs("researcher", 2) + + assert len(interactions) == 2 + assert all(item["member"] == "researcher" for item in interactions) + assert all(item["invocation_id"] in {"inv-2", "inv-3"} for item in interactions) + assert all(item["task"] != "Old task" for item in interactions) + + def test_get_member_interactions_for_runs_zero_returns_empty(self): + """Test member interactions returns empty when num_runs <= 0.""" + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task", "Response") + + assert ctx.get_member_interactions_for_runs("researcher", 0) == [] + assert ctx.get_member_interactions_for_runs("researcher", -1) == [] + + def test_get_member_interactions_for_runs_legacy_entries(self): + """Test member interactions fallback works for legacy entries without invocation_id.""" + ctx = TeamRunContext() + ctx.add_interaction("researcher", "Task 1", "Response 1") + ctx.add_interaction("writer", "Task 2", "Response 2") + + interactions = ctx.get_member_interactions_for_runs("researcher", 1) + assert len(interactions) == 1 + assert interactions[0]["member"] == "researcher" + + +class TestTeamRunContextLeaderHistory: + """Tests for leader history management.""" + + def test_add_leader_message_user(self): + """Test adding user message to leader history.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_leader_message("user", "Hello, please help me") + + assert len(ctx.leader_history) == 1 + assert ctx.leader_history[0]["role"] == "user" + assert ctx.leader_history[0]["text"] == "Hello, please help me" + assert ctx.leader_history[0]["invocation_id"] == "inv-123" + + def test_add_leader_message_model(self): + """Test adding model message to leader history.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_leader_message("model", "I will help you") + + assert len(ctx.leader_history) == 1 + assert ctx.leader_history[0]["role"] == "model" + + def test_add_leader_message_empty_text_ignored(self): + """Test that empty messages are not added to history.""" + ctx = TeamRunContext() + ctx.add_leader_message("user", "") + ctx.add_leader_message("user", " ") # whitespace only + + assert len(ctx.leader_history) == 0 + + def test_add_delegation_record(self): + """Test adding delegation record to leader history.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_delegation_record("researcher", "Find data", "Found data") + + assert len(ctx.leader_history) == 1 + record = ctx.leader_history[0] + assert record["role"] == "model" + assert "researcher" in record["text"] + assert "Find data" in record["text"] + assert "Found data" in record["text"] + assert "" in record["text"] + assert "" in record["text"] + + def test_get_leader_history_for_runs(self): + """Test filtering leader history by number of runs.""" + ctx = TeamRunContext() + + # Add history from invocation 1 + ctx.current_invocation_id = "inv-1" + ctx.add_leader_message("user", "Message 1") + ctx.add_leader_message("model", "Response 1") + + # Add history from invocation 2 + ctx.current_invocation_id = "inv-2" + ctx.add_leader_message("user", "Message 2") + ctx.add_leader_message("model", "Response 2") + + # Add history from invocation 3 + ctx.current_invocation_id = "inv-3" + ctx.add_leader_message("user", "Message 3") + ctx.add_leader_message("model", "Response 3") + + # Get last 2 runs + history = ctx.get_leader_history_for_runs(2) + invocation_ids = set(h["invocation_id"] for h in history) + assert invocation_ids == {"inv-2", "inv-3"} + + def test_get_leader_history_for_runs_zero(self): + """Test getting history with num_runs=0 returns empty list.""" + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Message") + + history = ctx.get_leader_history_for_runs(0) + assert history == [] + + def test_get_leader_history_for_runs_negative(self): + """Test getting history with negative num_runs returns empty list.""" + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Message") + + history = ctx.get_leader_history_for_runs(-1) + assert history == [] + + def test_get_leader_history_for_runs_all(self): + """Test getting more runs than available returns all history.""" + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_leader_message("user", "Message 1") + ctx.add_leader_message("model", "Response 1") + + history = ctx.get_leader_history_for_runs(10) # More than available + assert len(history) == 2 + + +class TestTeamRunContextHITL: + """Tests for Human-in-the-loop (HITL) functionality.""" + + def test_set_pending_hitl(self): + """Test setting pending HITL state.""" + ctx = TeamRunContext() + ctx.set_pending_hitl("func-call-123") + + assert ctx.pending_function_call_id == "func-call-123" + assert ctx.has_pending_hitl() is True + + def test_clear_pending_hitl(self): + """Test clearing pending HITL state.""" + ctx = TeamRunContext(pending_function_call_id="func-call-123") + ctx.clear_pending_hitl() + + assert ctx.pending_function_call_id == "" + assert ctx.has_pending_hitl() is False + + def test_has_pending_hitl_false(self): + """Test has_pending_hitl returns False when no pending state.""" + ctx = TeamRunContext() + assert ctx.has_pending_hitl() is False + + def test_has_pending_hitl_true(self): + """Test has_pending_hitl returns True when pending state exists.""" + ctx = TeamRunContext(pending_function_call_id="func-123") + assert ctx.has_pending_hitl() is True + + +class TestTeamRunContextStateSerialization: + """Tests for state serialization and deserialization.""" + + def test_to_state_dict(self): + """Test serializing TeamRunContext to state dictionary.""" + ctx = TeamRunContext( + team_name="test_team", + current_invocation_id="inv-123", + pending_function_call_id="func-456", + ) + ctx.add_leader_message("user", "Hello") + ctx.add_interaction("researcher", "Task", "Response") + + state_dict = ctx.to_state_dict() + + assert state_dict["team_name"] == "test_team" + assert state_dict["current_invocation_id"] == "inv-123" + assert state_dict["pending_function_call_id"] == "func-456" + assert len(state_dict["leader_history"]) == 1 + assert len(state_dict["interactions"]) == 1 + + def test_from_state_empty(self): + """Test restoring TeamRunContext from empty state.""" + ctx = TeamRunContext.from_state({}, team_name="test_team") + + assert ctx.team_name == "test_team" + assert ctx.interactions == [] + assert ctx.leader_history == [] + + def test_from_state_with_data(self): + """Test restoring TeamRunContext from state with data.""" + state = { + TEAM_STATE_KEY: { + "team_name": "test_team", + "interactions": [{ + "member": "researcher", + "task": "Task", + "response": "Response", + "invocation_id": "inv-1" + }], + "leader_history": [{ + "role": "user", + "text": "Hello", + "invocation_id": "inv-1" + }], + "current_invocation_id": "inv-1", + "pending_function_call_id": "func-123", + } + } + + ctx = TeamRunContext.from_state(state) + + assert ctx.team_name == "test_team" + assert len(ctx.interactions) == 1 + assert ctx.interactions[0]["member"] == "researcher" + assert len(ctx.leader_history) == 1 + assert ctx.pending_function_call_id == "func-123" + + def test_get_state_delta(self): + """Test getting state delta for session update.""" + ctx = TeamRunContext(team_name="test_team") + ctx.add_leader_message("user", "Hello") + + delta = ctx.get_state_delta() + + assert TEAM_STATE_KEY in delta + assert delta[TEAM_STATE_KEY]["team_name"] == "test_team" + assert len(delta[TEAM_STATE_KEY]["leader_history"]) == 1 + + def test_roundtrip_serialization(self): + """Test full roundtrip: create -> serialize -> deserialize.""" + original = TeamRunContext( + team_name="test_team", + current_invocation_id="inv-123", + ) + original.add_leader_message("user", "Hello") + original.add_leader_message("model", "Hi there") + original.add_interaction("researcher", "Find info", "Found it") + original.add_delegation_record("writer", "Write article", "Article written") + original.set_pending_hitl("func-456") + + # Serialize + state_dict = original.to_state_dict() + state = {TEAM_STATE_KEY: state_dict} + + # Deserialize + restored = TeamRunContext.from_state(state) + + # Verify + assert restored.team_name == original.team_name + assert restored.current_invocation_id == original.current_invocation_id + assert restored.pending_function_call_id == original.pending_function_call_id + assert len(restored.leader_history) == len(original.leader_history) + assert len(restored.interactions) == len(original.interactions) + + # Verify content + for i, h in enumerate(original.leader_history): + assert restored.leader_history[i]["role"] == h["role"] + assert restored.leader_history[i]["text"] == h["text"] + + +class TestTeamRunContextCancellation: + """Tests for cancellation record functionality.""" + + def test_add_cancellation_record_without_context(self): + """Test adding cancellation record without cancelled_during info.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_cancellation_record() + + assert len(ctx.leader_history) == 1 + assert ctx.leader_history[0]["role"] == "model" + assert "cancelled" in ctx.leader_history[0]["text"].lower() + assert ctx.leader_history[0]["invocation_id"] == "inv-123" + + def test_add_cancellation_record_with_context(self): + """Test adding cancellation record with cancelled_during description.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_cancellation_record(cancelled_during="delegation to researcher") + + assert len(ctx.leader_history) == 1 + text = ctx.leader_history[0]["text"] + assert "cancelled" in text.lower() + assert "delegation to researcher" in text + + def test_add_cancellation_record_empty_string(self): + """Test adding cancellation record with empty cancelled_during.""" + ctx = TeamRunContext(current_invocation_id="inv-123") + ctx.add_cancellation_record(cancelled_during="") + + assert len(ctx.leader_history) == 1 + assert "during" not in ctx.leader_history[0]["text"] + + +class TestTeamRunContextLegacyEntries: + """Tests for legacy entries without invocation IDs.""" + + def test_get_leader_history_for_runs_legacy_entries(self): + """Test leader history returns all entries when no invocation IDs found.""" + ctx = TeamRunContext() + ctx.leader_history = [ + {"role": "user", "text": "Question 1"}, + {"role": "model", "text": "Answer 1"}, + {"role": "user", "text": "Question 2"}, + ] + + history = ctx.get_leader_history_for_runs(1) + + assert len(history) == 3 + + def test_get_leader_history_for_runs_mixed_legacy_and_new(self): + """Test leader history with mixed legacy and invocation-tagged entries.""" + ctx = TeamRunContext() + ctx.leader_history = [ + {"role": "user", "text": "Legacy question"}, + {"role": "user", "text": "New question", "invocation_id": "inv-1"}, + {"role": "model", "text": "New answer", "invocation_id": "inv-1"}, + ] + + history = ctx.get_leader_history_for_runs(1) + assert len(history) == 3 + + def test_get_member_interactions_for_runs_empty_member_name(self): + """Test member interactions with empty member name returns empty.""" + ctx = TeamRunContext(current_invocation_id="inv-1") + ctx.add_interaction("researcher", "Task", "Response") + + assert ctx.get_member_interactions_for_runs("", 1) == [] + + def test_get_member_interactions_for_runs_empty_interactions(self): + """Test member interactions with no interactions returns empty.""" + ctx = TeamRunContext() + + assert ctx.get_member_interactions_for_runs("researcher", 1) == [] + + def test_get_leader_history_for_runs_empty_history(self): + """Test leader history with empty history returns empty.""" + ctx = TeamRunContext() + + assert ctx.get_leader_history_for_runs(5) == [] + + +class TestTeamRunContextFromStateEdgeCases: + """Tests for from_state edge cases.""" + + def test_from_state_partial_data(self): + """Test restoring from state with partial data (missing keys).""" + state = { + TEAM_STATE_KEY: { + "team_name": "test_team", + } + } + + ctx = TeamRunContext.from_state(state) + + assert ctx.team_name == "test_team" + assert ctx.interactions == [] + assert ctx.leader_history == [] + assert ctx.current_invocation_id == "" + assert ctx.pending_function_call_id == "" + + def test_from_state_none_state_key(self): + """Test restoring from state where TEAM_STATE_KEY is None.""" + state = {TEAM_STATE_KEY: None} + + ctx = TeamRunContext.from_state(state, team_name="fallback_name") + + assert ctx.team_name == "fallback_name" + assert ctx.interactions == [] + + def test_to_state_dict_produces_copies(self): + """Test that to_state_dict produces independent copies.""" + ctx = TeamRunContext(team_name="test") + ctx.add_interaction("member", "task", "response") + ctx.add_leader_message("user", "hello") + + state_dict = ctx.to_state_dict() + + ctx.interactions.append({"member": "extra", "task": "t", "response": "r"}) + ctx.leader_history.append({"role": "user", "text": "extra"}) + + assert len(state_dict["interactions"]) == 1 + assert len(state_dict["leader_history"]) == 1 + + +class TestTeamRunContextClear: + """Tests for clearing context.""" + + def test_clear(self): + """Test clearing all context data.""" + ctx = TeamRunContext( + team_name="test_team", + current_invocation_id="inv-123", + ) + ctx.add_leader_message("user", "Hello") + ctx.add_interaction("researcher", "Task", "Response") + ctx.set_pending_hitl("func-123") + + ctx.clear() + + assert ctx.interactions == [] + assert ctx.leader_history == [] + assert ctx.pending_function_call_id == "" + # team_name and current_invocation_id should remain + assert ctx.team_name == "test_team" + assert ctx.current_invocation_id == "inv-123" diff --git a/tests/telemetry/__init__.py b/tests/telemetry/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/tests/telemetry/__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 Apache-2.0. diff --git a/tests/telemetry/test_custom_metrics.py b/tests/telemetry/test_custom_metrics.py new file mode 100644 index 000000000..91cd740d4 --- /dev/null +++ b/tests/telemetry/test_custom_metrics.py @@ -0,0 +1,224 @@ +# 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 :class:`trpc_agent_sdk.telemetry.CustomMetricsReporter`. + +Verifies the event-routing state machine: + * partial events bump TTFT only + * function-call events close an LLM segment and start tool timers + * function-response events close tool timers and reopen an LLM segment + * plain content events close an LLM segment + +All ``report_*`` functions are patched to record the calls instead of emitting +to OTel, so the tests are hermetic. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from typing import Dict +from typing import List +from unittest.mock import patch + +import pytest + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.telemetry import CustomMetricsReporter +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +def _ctx(): + return SimpleNamespace( + app_name="demo", + user_id="alice", + agent_name="asst", + agent=SimpleNamespace(model=None), + ) + + +def _text_event(text: str, *, partial: bool = False) -> Event: + return Event( + invocation_id="inv-1", + author="asst", + partial=partial, + content=Content(parts=[Part.from_text(text=text)], role="model"), + ) + + +def _function_call_event(call_id: str, name: str) -> Event: + return Event( + invocation_id="inv-1", + author="asst", + content=Content( + parts=[Part(function_call={ + "id": call_id, + "name": name, + "args": { + "x": 1 + }, + })], + role="model", + ), + ) + + +def _function_response_event(call_id: str, name: str, *, error: bool = False) -> Event: + ev = Event( + invocation_id="inv-1", + author="tool", + content=Content( + parts=[Part(function_response={ + "id": call_id, + "name": name, + "response": { + "ok": not error + }, + })], + role="tool", + ), + ) + if error: + ev.error_code = "500" + return ev + + +class _Capture: + """Helper to capture kwargs from patched ``report_*`` functions.""" + + def __init__(self): + self.calls: List[Dict[str, Any]] = [] + + def __call__(self, *args, **kwargs): + self.calls.append({"args": args, "kwargs": kwargs}) + + +@pytest.fixture() +def patched_reporters(): + """Patch the two ``report_*`` functions imported into ``_custom_metrics``.""" + llm = _Capture() + tool = _Capture() + with patch("trpc_agent_sdk.telemetry._custom_metrics.report_call_llm", + new=llm), patch("trpc_agent_sdk.telemetry._custom_metrics.report_execute_tool", new=tool): + yield llm, tool + + +class TestCustomMetricsReporterRouting: + + def test_plain_content_event_emits_call_llm(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst", model_prefix="claude") + + reporter.report_event(_ctx(), _text_event("hello")) + + assert len(llm.calls) == 1 + assert len(tool.calls) == 0 + kw = llm.calls[0]["kwargs"] + req = kw["llm_request"] + assert req.model == "claude:asst" + assert kw["is_stream"] is True # default + assert kw["duration_s"] >= 0.0 + assert kw["ttft_s"] >= 0.0 + + def test_partial_event_does_not_emit(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + + reporter.report_event(_ctx(), _text_event("chunk", partial=True)) + reporter.report_event(_ctx(), _text_event("chunk 2", partial=True)) + + assert llm.calls == [] + assert tool.calls == [] + # TTFT is latched as soon as any content event arrives, partial or not. + assert reporter._llm_ttft is not None + + def test_function_call_closes_llm_and_opens_tool_timers(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + + assert len(llm.calls) == 1, "function-call event must emit the open LLM segment" + assert len(tool.calls) == 0 + assert reporter._pending_tool_starts.keys() == {"c1"} + assert reporter._pending_tool_starts["c1"][0] == "search" + assert reporter._llm_segment_start is None + + def test_function_response_emits_execute_tool_and_reopens_segment(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + reporter.report_event(_ctx(), _function_response_event("c1", "search")) + + assert len(tool.calls) == 1 + kw = tool.calls[0]["kwargs"] + assert kw["duration_s"] >= 0.0 + assert kw["error_type"] is None + assert tool.calls[0]["args"][1].name == "search" + assert reporter._pending_tool_starts == {} + assert reporter._llm_segment_start is not None + + def test_tool_error_type_propagates(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + reporter.report_event(_ctx(), _function_response_event("c1", "search", error=True)) + + assert tool.calls[0]["kwargs"]["error_type"] == "500" + + def test_unmatched_function_response_is_ignored(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + + # No matching function_call beforehand. + reporter.report_event(_ctx(), _function_response_event("unknown", "search")) + + assert tool.calls == [] + # Segment was still reopened. + assert reporter._llm_segment_start is not None + + def test_full_round_trip_chat_tool_chat(self, patched_reporters): + """LLM call -> tool call -> tool result -> final LLM chunk.""" + llm, tool = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst", model_prefix="a2a") + + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + reporter.report_event(_ctx(), _function_response_event("c1", "search")) + reporter.report_event(_ctx(), _text_event("final answer")) + + assert len(llm.calls) == 2, "two LLM segments: before tool + after tool" + assert len(tool.calls) == 1 + for call in llm.calls: + assert call["kwargs"]["llm_request"].model == "a2a:asst" + + def test_extra_attributes_forwarded(self, patched_reporters): + llm, tool = patched_reporters + reporter = CustomMetricsReporter( + agent_name="asst", + extra_attributes={"gen_ai.system": "openai"}, + ) + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + reporter.report_event(_ctx(), _function_response_event("c1", "search")) + + assert llm.calls[0]["kwargs"]["extra_attributes"] == {"gen_ai.system": "openai"} + assert tool.calls[0]["kwargs"]["extra_attributes"] == {"gen_ai.system": "openai"} + + +class TestCustomMetricsReporterReset: + + def test_reset_clears_pending_state(self, patched_reporters): + _, _ = patched_reporters + reporter = CustomMetricsReporter(agent_name="asst") + reporter.report_event(_ctx(), _function_call_event("c1", "search")) + assert reporter._pending_tool_starts + + reporter.reset() + + assert reporter._pending_tool_starts == {} + assert reporter._llm_segment_start is None + assert reporter._llm_ttft is None diff --git a/tests/telemetry/test_custom_trace.py b/tests/telemetry/test_custom_trace.py new file mode 100644 index 000000000..02f27ab14 --- /dev/null +++ b/tests/telemetry/test_custom_trace.py @@ -0,0 +1,619 @@ +# 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 trpc_agent_sdk.telemetry._custom_trace. + +Covers: +- _SyntheticTool: init, name, description, _run_async_impl raises +- CustomTraceReporter: + - __init__ (defaults, custom params, text_content_filter) + - _create_synthetic_llm_request (with/without user_content) + - _create_synthetic_llm_response (with/without event) + - _trace_function_call (single, multiple) + - _trace_function_response (matched, unmatched, multiple) + - _trace_llm_response (with/without instruction metadata) + - _should_trace_text (empty, filter, no filter) + - trace_event (partial skip, function_call, function_response, text, no text) + - reset +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from trpc_agent_sdk.telemetry._custom_trace import ( + CustomTraceReporter, + _SyntheticTool, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_invocation_context(agent_name="test_agent", session_id="sess-1", + user_id="user-1", user_content=None, + invocation_id="inv-1", instruction=None): + ctx = MagicMock() + ctx.agent = MagicMock() + ctx.agent.name = agent_name + ctx.agent.instruction = instruction + ctx.invocation_id = invocation_id + ctx.user_content = user_content + ctx.session = MagicMock() + ctx.session.id = session_id + ctx.session.user_id = user_id + return ctx + + +def _make_function_call(name="tool_a", fc_id="fc-1", args=None): + fc = MagicMock() + fc.name = name + fc.id = fc_id + fc.args = args + return fc + + +def _make_function_response(resp_id="fc-1", response=None): + fr = MagicMock() + fr.id = resp_id + fr.response = response if response is not None else {"result": "ok"} + return fr + + +def _make_event( + partial=False, + function_calls=None, + function_responses=None, + text="", + event_id="evt-1", + content=None, + error_message=None, +): + event = MagicMock() + event.partial = partial + event.id = event_id + event.content = content + event.error_message = error_message + event.get_function_calls = MagicMock(return_value=function_calls or []) + event.get_function_responses = MagicMock(return_value=function_responses or []) + event.get_text = MagicMock(return_value=text) + return event + + +# --------------------------------------------------------------------------- +# Tests: _SyntheticTool +# --------------------------------------------------------------------------- + +class TestSyntheticTool: + def test_init_with_name_and_description(self): + tool = _SyntheticTool(name="my_tool", description="My tool desc") + assert tool.name == "my_tool" + assert tool.description == "My tool desc" + + def test_init_default_description(self): + tool = _SyntheticTool(name="my_tool") + assert tool.description == "Custom tool: my_tool" + + def test_init_empty_description_uses_default(self): + tool = _SyntheticTool(name="t", description="") + assert tool.description == "Custom tool: t" + + @pytest.mark.asyncio + async def test_run_async_impl_raises(self): + tool = _SyntheticTool(name="t") + with pytest.raises(NotImplementedError, match="Synthetic tool should not be executed"): + await tool._run_async_impl(tool_context=None, args={}) + + +# --------------------------------------------------------------------------- +# Tests: CustomTraceReporter.__init__ +# --------------------------------------------------------------------------- + +class TestCustomTraceReporterInit: + def test_default_init(self): + reporter = CustomTraceReporter(agent_name="agent_a") + assert reporter.agent_name == "agent_a" + assert reporter.model_prefix == "custom" + assert reporter.tool_description_prefix == "Custom tool" + assert reporter.text_content_filter is None + assert reporter.pending_function_calls == {} + + def test_custom_params(self): + filt = lambda text: len(text) > 5 + reporter = CustomTraceReporter( + agent_name="agent_b", + model_prefix="a2a", + tool_description_prefix="Remote tool", + text_content_filter=filt, + ) + assert reporter.model_prefix == "a2a" + assert reporter.tool_description_prefix == "Remote tool" + assert reporter.text_content_filter is filt + + +# --------------------------------------------------------------------------- +# Tests: _create_synthetic_llm_request +# --------------------------------------------------------------------------- + +class TestCreateSyntheticLlmRequest: + @patch("trpc_agent_sdk.telemetry._custom_trace.LlmRequest") + @patch("trpc_agent_sdk.telemetry._custom_trace.GenerateContentConfig") + def test_with_user_content(self, MockConfig, MockLlmRequest): + reporter = CustomTraceReporter(agent_name="agent_x", model_prefix="pfx") + user_content = MagicMock() + ctx = _make_invocation_context(user_content=user_content) + + config_instance = MagicMock() + MockConfig.return_value = config_instance + + reporter._create_synthetic_llm_request(ctx) + + MockLlmRequest.assert_called_once_with( + model="pfx:agent_x", + contents=[user_content], + config=config_instance, + ) + + @patch("trpc_agent_sdk.telemetry._custom_trace.LlmRequest") + @patch("trpc_agent_sdk.telemetry._custom_trace.GenerateContentConfig") + def test_without_user_content(self, MockConfig, MockLlmRequest): + reporter = CustomTraceReporter(agent_name="agent_x") + ctx = _make_invocation_context(user_content=None) + + config_instance = MagicMock() + MockConfig.return_value = config_instance + + reporter._create_synthetic_llm_request(ctx) + + MockLlmRequest.assert_called_once_with( + model="custom:agent_x", + contents=[], + config=config_instance, + ) + + +# --------------------------------------------------------------------------- +# Tests: _create_synthetic_llm_response +# --------------------------------------------------------------------------- + +class TestCreateSyntheticLlmResponse: + @patch("trpc_agent_sdk.telemetry._custom_trace.LlmResponse") + def test_with_event(self, MockLlmResponse): + reporter = CustomTraceReporter(agent_name="a") + event = _make_event(content="content_obj", error_message="err") + + reporter._create_synthetic_llm_response(event) + + MockLlmResponse.assert_called_once_with( + content="content_obj", + error_message="err", + ) + + @patch("trpc_agent_sdk.telemetry._custom_trace.LlmResponse") + def test_with_none_event(self, MockLlmResponse): + reporter = CustomTraceReporter(agent_name="a") + event = None + + reporter._create_synthetic_llm_response(event) + + MockLlmResponse.assert_called_once_with( + content=None, + error_message=None, + ) + + +# --------------------------------------------------------------------------- +# Tests: _trace_function_call +# --------------------------------------------------------------------------- + +class TestTraceFunctionCall: + def test_single_function_call(self): + reporter = CustomTraceReporter(agent_name="a") + fc = _make_function_call(name="tool_1", fc_id="fc-1", args={"k": "v"}) + event = _make_event(function_calls=[fc]) + + reporter._trace_function_call(event) + + assert "fc-1" in reporter.pending_function_calls + assert reporter.pending_function_calls["fc-1"]["name"] == "tool_1" + assert reporter.pending_function_calls["fc-1"]["args"] == {"k": "v"} + + def test_multiple_function_calls(self): + reporter = CustomTraceReporter(agent_name="a") + fc1 = _make_function_call(name="t1", fc_id="fc-1", args={"a": 1}) + fc2 = _make_function_call(name="t2", fc_id="fc-2", args=None) + event = _make_event(function_calls=[fc1, fc2]) + + reporter._trace_function_call(event) + + assert len(reporter.pending_function_calls) == 2 + assert reporter.pending_function_calls["fc-2"]["args"] == {} + + def test_args_none_becomes_empty_dict(self): + reporter = CustomTraceReporter(agent_name="a") + fc = _make_function_call(name="t", fc_id="fc-1", args=None) + event = _make_event(function_calls=[fc]) + + reporter._trace_function_call(event) + + assert reporter.pending_function_calls["fc-1"]["args"] == {} + + +# --------------------------------------------------------------------------- +# Tests: _trace_function_response +# --------------------------------------------------------------------------- + +class TestTraceFunctionResponse: + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_tool_call") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_matched_response(self, mock_tracer, mock_trace_tool_call): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter( + agent_name="a", + tool_description_prefix="Test tool", + ) + reporter.pending_function_calls["fc-1"] = { + "name": "tool_x", + "args": {"input": "val"}, + "id": "fc-1", + } + + fr = _make_function_response(resp_id="fc-1") + event = _make_event(function_responses=[fr]) + + reporter._trace_function_response(event) + + mock_tracer.start_as_current_span.assert_called_once_with("execute_tool tool_x") + mock_trace_tool_call.assert_called_once() + call_kwargs = mock_trace_tool_call.call_args.kwargs + assert call_kwargs["args"] == {"input": "val"} + assert call_kwargs["function_response_event"] is event + assert "fc-1" not in reporter.pending_function_calls + + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_tool_call") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_unmatched_response_ignored(self, mock_tracer, mock_trace_tool_call): + reporter = CustomTraceReporter(agent_name="a") + fr = _make_function_response(resp_id="unknown-id") + event = _make_event(function_responses=[fr]) + + reporter._trace_function_response(event) + + mock_trace_tool_call.assert_not_called() + + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_tool_call") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_multiple_responses(self, mock_tracer, mock_trace_tool_call): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="a") + reporter.pending_function_calls["fc-1"] = { + "name": "t1", "args": {}, "id": "fc-1", + } + reporter.pending_function_calls["fc-2"] = { + "name": "t2", "args": {}, "id": "fc-2", + } + + fr1 = _make_function_response(resp_id="fc-1") + fr2 = _make_function_response(resp_id="fc-2") + event = _make_event(function_responses=[fr1, fr2]) + + reporter._trace_function_response(event) + + assert mock_trace_tool_call.call_count == 2 + assert len(reporter.pending_function_calls) == 0 + + +# --------------------------------------------------------------------------- +# Tests: _trace_llm_response +# --------------------------------------------------------------------------- + +class TestTraceLlmResponse: + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_call_llm") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_traces_llm_call(self, mock_tracer, mock_trace_call_llm): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="a", model_prefix="pfx") + ctx = _make_invocation_context() + event = _make_event(event_id="e-1") + + with patch.object(reporter, "_create_synthetic_llm_request") as mock_req, \ + patch.object(reporter, "_create_synthetic_llm_response") as mock_resp: + mock_req.return_value = "fake_request" + mock_resp.return_value = "fake_response" + + reporter._trace_llm_response(ctx, event) + + mock_tracer.start_as_current_span.assert_called_once_with("call_llm") + mock_trace_call_llm.assert_called_once() + call_kwargs = mock_trace_call_llm.call_args.kwargs + assert call_kwargs["invocation_context"] is ctx + assert call_kwargs["event_id"] == "e-1" + assert call_kwargs["llm_request"] == "fake_request" + assert call_kwargs["llm_response"] == "fake_response" + + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_call_llm") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_with_instruction_metadata(self, mock_tracer, mock_trace_call_llm): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="a") + mock_metadata = MagicMock() + mock_instruction = MagicMock() + mock_instruction.metadata = mock_metadata + ctx = _make_invocation_context(instruction=mock_instruction) + event = _make_event() + + with patch.object(reporter, "_create_synthetic_llm_request") as mock_req, \ + patch.object(reporter, "_create_synthetic_llm_response") as mock_resp: + mock_req.return_value = MagicMock() + mock_resp.return_value = MagicMock() + + reporter._trace_llm_response(ctx, event) + + call_kwargs = mock_trace_call_llm.call_args.kwargs + assert call_kwargs["instruction_metadata"] is mock_metadata + + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_call_llm") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_no_instruction(self, mock_tracer, mock_trace_call_llm): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + ctx.agent.instruction = None + event = _make_event() + + with patch.object(reporter, "_create_synthetic_llm_request") as mock_req, \ + patch.object(reporter, "_create_synthetic_llm_response") as mock_resp: + mock_req.return_value = MagicMock() + mock_resp.return_value = MagicMock() + + reporter._trace_llm_response(ctx, event) + + call_kwargs = mock_trace_call_llm.call_args.kwargs + assert call_kwargs["instruction_metadata"] is None + + +# --------------------------------------------------------------------------- +# Tests: _should_trace_text +# --------------------------------------------------------------------------- + +class TestShouldTraceText: + def test_empty_text_returns_false(self): + reporter = CustomTraceReporter(agent_name="a") + assert reporter._should_trace_text("") is False + + def test_non_empty_text_no_filter_returns_true(self): + reporter = CustomTraceReporter(agent_name="a") + assert reporter._should_trace_text("hello") is True + + def test_filter_returns_true(self): + reporter = CustomTraceReporter( + agent_name="a", + text_content_filter=lambda t: "ok" in t, + ) + assert reporter._should_trace_text("this is ok") is True + + def test_filter_returns_false(self): + reporter = CustomTraceReporter( + agent_name="a", + text_content_filter=lambda t: "ok" in t, + ) + assert reporter._should_trace_text("not matching") is False + + def test_filter_with_none_text(self): + reporter = CustomTraceReporter( + agent_name="a", + text_content_filter=lambda t: True, + ) + assert reporter._should_trace_text("") is False + + +# --------------------------------------------------------------------------- +# Tests: trace_event +# --------------------------------------------------------------------------- + +class TestTraceEvent: + def test_skip_partial_event(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + event = _make_event(partial=True) + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_function_response") as m_fr, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_fc.assert_not_called() + m_fr.assert_not_called() + m_llm.assert_not_called() + + def test_function_call_event(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + fc = _make_function_call() + event = _make_event(function_calls=[fc]) + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_function_response") as m_fr, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_fc.assert_called_once_with(event) + m_fr.assert_not_called() + m_llm.assert_not_called() + + def test_function_response_event(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + fr = _make_function_response() + event = _make_event(function_responses=[fr]) + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_function_response") as m_fr, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_fc.assert_not_called() + m_fr.assert_called_once_with(event) + m_llm.assert_not_called() + + def test_text_event_traces_llm(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + event = _make_event(text="some response text") + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_function_response") as m_fr, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_fc.assert_not_called() + m_fr.assert_not_called() + m_llm.assert_called_once_with(ctx, event) + + def test_empty_text_event_skips_llm_trace(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + event = _make_event(text="") + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_function_response") as m_fr, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_llm.assert_not_called() + + def test_text_filtered_out_skips_llm_trace(self): + reporter = CustomTraceReporter( + agent_name="a", + text_content_filter=lambda t: False, + ) + ctx = _make_invocation_context() + event = _make_event(text="some text") + + with patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_llm.assert_not_called() + + def test_function_call_takes_priority_over_text(self): + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + fc = _make_function_call() + event = _make_event(function_calls=[fc], text="also has text") + + with patch.object(reporter, "_trace_function_call") as m_fc, \ + patch.object(reporter, "_trace_llm_response") as m_llm: + reporter.trace_event(ctx, event) + + m_fc.assert_called_once() + m_llm.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests: reset +# --------------------------------------------------------------------------- + +class TestReset: + def test_reset_clears_pending(self): + reporter = CustomTraceReporter(agent_name="a") + reporter.pending_function_calls["fc-1"] = {"name": "t", "args": {}, "id": "fc-1"} + reporter.pending_function_calls["fc-2"] = {"name": "t2", "args": {}, "id": "fc-2"} + + reporter.reset() + + assert reporter.pending_function_calls == {} + + def test_reset_idempotent(self): + reporter = CustomTraceReporter(agent_name="a") + reporter.reset() + reporter.reset() + assert reporter.pending_function_calls == {} + + +# --------------------------------------------------------------------------- +# Tests: Integration-like end-to-end flow +# --------------------------------------------------------------------------- + +class TestEndToEndFlow: + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_call_llm") + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_tool_call") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_full_flow_fc_then_fr_then_text( + self, mock_tracer, mock_trace_tool_call, mock_trace_call_llm + ): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="my_agent", model_prefix="test") + ctx = _make_invocation_context() + + # Step 1: function call event + fc = _make_function_call(name="search", fc_id="fc-99", args={"q": "hello"}) + fc_event = _make_event(function_calls=[fc]) + reporter.trace_event(ctx, fc_event) + + assert "fc-99" in reporter.pending_function_calls + mock_trace_tool_call.assert_not_called() + + # Step 2: function response event + fr = _make_function_response(resp_id="fc-99", response={"answer": "world"}) + fr_event = _make_event(function_responses=[fr]) + reporter.trace_event(ctx, fr_event) + + mock_trace_tool_call.assert_called_once() + assert "fc-99" not in reporter.pending_function_calls + + # Step 3: text response event (LLM trace) + text_event = _make_event(text="Final answer: world") + reporter.trace_event(ctx, text_event) + + mock_trace_call_llm.assert_called_once() + + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_call_llm") + @patch("trpc_agent_sdk.telemetry._custom_trace.trace_tool_call") + @patch("trpc_agent_sdk.telemetry._custom_trace.tracer") + def test_reset_between_invocations( + self, mock_tracer, mock_trace_tool_call, mock_trace_call_llm + ): + mock_tracer.start_as_current_span = MagicMock() + mock_tracer.start_as_current_span.return_value.__enter__ = MagicMock() + mock_tracer.start_as_current_span.return_value.__exit__ = MagicMock(return_value=False) + + reporter = CustomTraceReporter(agent_name="a") + ctx = _make_invocation_context() + + fc = _make_function_call(name="t", fc_id="fc-1", args={}) + fc_event = _make_event(function_calls=[fc]) + reporter.trace_event(ctx, fc_event) + + assert len(reporter.pending_function_calls) == 1 + + reporter.reset() + assert len(reporter.pending_function_calls) == 0 + + fr = _make_function_response(resp_id="fc-1") + fr_event = _make_event(function_responses=[fr]) + reporter.trace_event(ctx, fr_event) + + mock_trace_tool_call.assert_not_called() diff --git a/tests/telemetry/test_metrics.py b/tests/telemetry/test_metrics.py new file mode 100644 index 000000000..fc96ec07a --- /dev/null +++ b/tests/telemetry/test_metrics.py @@ -0,0 +1,553 @@ +# 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 :mod:`trpc_agent_sdk.telemetry._metrics`. + +Uses an ``InMemoryMetricReader`` bound to a private ``MeterProvider`` so the +tests can inspect the OTel data points the ``report_*`` functions emit, without +touching the global meter provider. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from typing import Dict +from typing import Mapping +from typing import Optional + +import pytest +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader + +from trpc_agent_sdk.telemetry import _metrics as tmetrics + + +def _make_ctx( + *, + app_name: str = "demo", + user_id: str = "alice", + agent_name: str = "asst", + agent_model: Any = None, +) -> SimpleNamespace: + """Build a duck-typed ``InvocationContext`` stub. + + The ``report_*`` functions only read ``app_name``, ``user_id``, + ``agent_name``, and ``agent`` from the context, so a ``SimpleNamespace`` is + sufficient and avoids having to construct a real session/agent. + """ + agent = SimpleNamespace(model=agent_model) + return SimpleNamespace( + app_name=app_name, + user_id=user_id, + agent_name=agent_name, + agent=agent, + ) + + +class _StubTool: + + def __init__(self, name: str): + self.name = name + + +class _StubLlmRequest: + + def __init__(self, model: str): + self.model = model + + +class _StubUsage: + + def __init__( + self, + prompt: int, + total: int, + cache_read: Optional[int] = None, + cache_creation: Optional[int] = None, + ): + self.prompt_token_count = prompt + self.total_token_count = total + self.cache_read_input_tokens = cache_read + self.cache_creation_input_tokens = cache_creation + + +class _StubLlmResponse: + + def __init__( + self, + *, + model: str = "", + error_code: str = "", + usage: Optional[_StubUsage] = None, + ): + self.model = model + self.error_code = error_code + self.usage_metadata = usage + + +@pytest.fixture() +def reader_provider(monkeypatch): + """Install an ``InMemoryMetricReader`` on a private ``MeterProvider``. + + Rebinds the module-level instruments in :mod:`trpc_agent_sdk.telemetry._metrics` + to the test meter so we can introspect emissions without global state. + """ + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + meter = provider.get_meter("test") + + originals = { + "_request_cnt": tmetrics._request_cnt, + "_operation_duration": tmetrics._operation_duration, + "_time_to_first_token": tmetrics._time_to_first_token, + "_usage_input_tokens": tmetrics._usage_input_tokens, + "_usage_output_tokens": tmetrics._usage_output_tokens, + "_usage_cache_read_tokens": tmetrics._usage_cache_read_tokens, + "_usage_cache_creation_tokens": tmetrics._usage_cache_creation_tokens, + } + monkeypatch.setattr( + tmetrics, + "_request_cnt", + meter.create_counter("gen_ai.request_cnt"), + ) + monkeypatch.setattr( + tmetrics, + "_operation_duration", + meter.create_histogram("gen_ai.client.operation.duration"), + ) + monkeypatch.setattr( + tmetrics, + "_time_to_first_token", + meter.create_histogram("gen_ai.server.time_to_first_token"), + ) + monkeypatch.setattr( + tmetrics, + "_usage_input_tokens", + meter.create_histogram("gen_ai.usage.input_tokens"), + ) + monkeypatch.setattr( + tmetrics, + "_usage_output_tokens", + meter.create_histogram("gen_ai.usage.output_tokens"), + ) + monkeypatch.setattr( + tmetrics, + "_usage_cache_read_tokens", + meter.create_histogram("gen_ai.usage.cache_read_input_tokens"), + ) + monkeypatch.setattr( + tmetrics, + "_usage_cache_creation_tokens", + meter.create_histogram("gen_ai.usage.cache_creation_input_tokens"), + ) + + yield reader, provider + + for name, inst in originals.items(): + monkeypatch.setattr(tmetrics, name, inst) + provider.shutdown() + + +def _collect(reader: InMemoryMetricReader) -> Dict[str, list]: + """Collect and index data points by metric name.""" + data = reader.get_metrics_data() + out: Dict[str, list] = {} + if data is None: + return out + for rm in data.resource_metrics: + for sm in rm.scope_metrics: + for metric in sm.metrics: + for dp in getattr(metric.data, "data_points", []) or []: + out.setdefault(metric.name, []).append(dp) + return out + + +def _attrs(dp) -> Mapping[str, Any]: + return dict(dp.attributes or {}) + + +class TestInferSystem: + """Vendor inference from model name.""" + + @pytest.mark.parametrize( + "model,expected", + [ + ("gpt-4", "openai"), + ("gpt-4o-mini", "openai"), + ("o1-preview", "openai"), + ("text-embedding-3-large", "openai"), + ("claude-3-5-sonnet", "anthropic"), + ("CLAUDE-opus", "anthropic"), + ("gemini-2.0-flash", "gcp.gemini"), + ("hunyuan-pro", "hunyuan"), + ("taiji-v1", "taiji"), + ("", ""), + ("unknown-model-x", ""), + ], + ) + def test_known_and_unknown(self, model: str, expected: str): + assert tmetrics._infer_system(model) == expected + + +class TestAgentModelName: + """Best-effort extraction of the agent's model name.""" + + def test_string_model(self): + agent = SimpleNamespace(model="claude-3-haiku") + assert tmetrics._agent_model_name(agent) == "claude-3-haiku" + + def test_model_with_name_property(self): + agent = SimpleNamespace(model=SimpleNamespace(name="gpt-4")) + assert tmetrics._agent_model_name(agent) == "gpt-4" + + def test_missing_model_attribute(self): + agent = SimpleNamespace() + assert tmetrics._agent_model_name(agent) == "" + + def test_model_is_none(self): + agent = SimpleNamespace(model=None) + assert tmetrics._agent_model_name(agent) == "" + + def test_model_is_callable(self): + """Callable factories (lazy agents) are not statically reachable.""" + agent = SimpleNamespace(model=lambda *_: None) + assert tmetrics._agent_model_name(agent) == "" + + +class TestMergeExtras: + + def test_no_extras(self): + base = {"a": 1} + assert tmetrics._merge_extras(base, None) is base + + def test_extras_override_base(self): + base = {"a": 1, "b": 2} + out = tmetrics._merge_extras(base, {"b": 3, "c": 4}) + assert out == {"a": 1, "b": 3, "c": 4} + assert base == {"a": 1, "b": 2}, "base must not be mutated" + + def test_none_values_are_skipped(self): + out = tmetrics._merge_extras({"a": 1}, {"a": None, "b": None, "c": 7}) + assert out == {"a": 1, "c": 7} + + +class TestReportCallLlm: + + def test_emits_request_cnt_duration_and_ttft(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + _StubLlmResponse(model="gpt-4-0613"), + duration_s=1.25, + ttft_s=0.2, + is_stream=True, + ) + metrics = _collect(reader) + + assert "gen_ai.request_cnt" in metrics + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.server.time_to_first_token" in metrics + assert "gen_ai.usage.input_tokens" not in metrics + assert "gen_ai.usage.output_tokens" not in metrics + + cnt_dp = metrics["gen_ai.request_cnt"][0] + assert cnt_dp.value == 1 + attrs = _attrs(cnt_dp) + assert attrs["gen_ai.operation.name"] == "chat" + assert attrs["gen_ai.system"] == "openai" + assert attrs["gen_ai.app.name"] == "demo" + assert attrs["gen_ai.user.id"] == "alice" + assert attrs["gen_ai.agent.id"] == "asst" + assert attrs["gen_ai.agent.name"] == "asst" + assert attrs["gen_ai.request.model"] == "gpt-4" + assert attrs["gen_ai.response.model"] == "gpt-4-0613" + assert attrs["gen_ai.is_stream"] is True + assert attrs["error.type"] == "" + assert attrs["gen_ai.response.error_code"] == "" + + dur_dp = metrics["gen_ai.client.operation.duration"][0] + assert dur_dp.sum == pytest.approx(1.25) + ttft_dp = metrics["gen_ai.server.time_to_first_token"][0] + assert ttft_dp.sum == pytest.approx(0.2) + + def test_usage_tokens_emitted_when_usage_metadata_present(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("claude-3-5-sonnet"), + _StubLlmResponse( + model="claude-3-5-sonnet", + usage=_StubUsage(prompt=120, total=170), + ), + duration_s=2.0, + ttft_s=0.3, + is_stream=False, + ) + metrics = _collect(reader) + + inp = metrics["gen_ai.usage.input_tokens"][0] + out = metrics["gen_ai.usage.output_tokens"][0] + assert inp.sum == 120 + assert out.sum == 50 + + def test_cache_usage_tokens_emitted_when_present(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("claude-3-5-sonnet"), + _StubLlmResponse( + model="claude-3-5-sonnet", + usage=_StubUsage(prompt=120, total=170, cache_read=80, cache_creation=40), + ), + duration_s=2.0, + ttft_s=0.3, + is_stream=False, + ) + metrics = _collect(reader) + + cache_read = metrics["gen_ai.usage.cache_read_input_tokens"][0] + cache_creation = metrics["gen_ai.usage.cache_creation_input_tokens"][0] + assert cache_read.sum == 80 + assert cache_creation.sum == 40 + + def test_usage_tokens_skipped_when_missing(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + None, + duration_s=1.0, + ttft_s=0.1, + is_stream=False, + ) + metrics = _collect(reader) + assert "gen_ai.usage.input_tokens" not in metrics + assert "gen_ai.usage.output_tokens" not in metrics + + def test_usage_zero_tokens_skipped(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + _StubLlmResponse(model="gpt-4", usage=_StubUsage(prompt=0, total=0)), + duration_s=1.0, + ttft_s=0.1, + is_stream=False, + ) + metrics = _collect(reader) + assert "gen_ai.usage.input_tokens" not in metrics + assert "gen_ai.usage.output_tokens" not in metrics + + def test_error_type_and_response_code_propagate(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + _StubLlmResponse(model="gpt-4", error_code="429"), + duration_s=0.1, + ttft_s=0.1, + is_stream=False, + error_type="rate_limit", + ) + cnt_dp = _collect(reader)["gen_ai.request_cnt"][0] + attrs = _attrs(cnt_dp) + assert attrs["error.type"] == "rate_limit" + assert attrs["gen_ai.response.error_code"] == "429" + + def test_extra_attributes_override_inferred_system(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx() + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("some-custom-model"), + _StubLlmResponse(model="some-custom-model"), + duration_s=0.1, + ttft_s=0.1, + is_stream=False, + extra_attributes={ + "gen_ai.system": "openai", + "user_ext1": "abc" + }, + ) + attrs = _attrs(_collect(reader)["gen_ai.request_cnt"][0]) + assert attrs["gen_ai.system"] == "openai" + assert attrs["user_ext1"] == "abc" + + +class TestReportExecuteTool: + + def test_emits_request_cnt_and_duration(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="hunyuan-pro") + tmetrics.report_execute_tool(ctx, _StubTool("search"), duration_s=0.5) + metrics = _collect(reader) + + assert "gen_ai.request_cnt" in metrics + assert "gen_ai.client.operation.duration" in metrics + assert "gen_ai.server.time_to_first_token" not in metrics + assert "gen_ai.usage.input_tokens" not in metrics + + attrs = _attrs(metrics["gen_ai.request_cnt"][0]) + assert attrs["gen_ai.operation.name"] == "execute_tool" + assert attrs["gen_ai.tool.name"] == "search" + assert attrs["gen_ai.system"] == "hunyuan" + assert attrs["gen_ai.app.name"] == "demo" + + def test_system_empty_when_agent_has_no_model(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model=None) + tmetrics.report_execute_tool(ctx, _StubTool("search"), duration_s=0.5) + attrs = _attrs(_collect(reader)["gen_ai.request_cnt"][0]) + assert attrs["gen_ai.system"] == "" + + def test_extra_attrs_can_supply_system(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model=None) + tmetrics.report_execute_tool( + ctx, + _StubTool("search"), + duration_s=0.5, + extra_attributes={"gen_ai.system": "openai"}, + ) + attrs = _attrs(_collect(reader)["gen_ai.request_cnt"][0]) + assert attrs["gen_ai.system"] == "openai" + + def test_error_type_propagates(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="claude-3-haiku") + tmetrics.report_execute_tool(ctx, _StubTool("search"), duration_s=0.5, error_type="timeout") + attrs = _attrs(_collect(reader)["gen_ai.request_cnt"][0]) + assert attrs["error.type"] == "timeout" + assert attrs["gen_ai.system"] == "anthropic" + + +class TestReportInvokeAgent: + + def test_emits_all_five_instruments(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + tmetrics.report_invoke_agent( + ctx, + duration_s=3.0, + ttft_s=0.4, + input_tokens=200, + output_tokens=50, + is_stream=True, + ) + metrics = _collect(reader) + + assert metrics["gen_ai.request_cnt"][0].value == 1 + assert metrics["gen_ai.client.operation.duration"][0].sum == pytest.approx(3.0) + assert metrics["gen_ai.server.time_to_first_token"][0].sum == pytest.approx(0.4) + assert metrics["gen_ai.usage.input_tokens"][0].sum == 200 + assert metrics["gen_ai.usage.output_tokens"][0].sum == 50 + + attrs = _attrs(metrics["gen_ai.request_cnt"][0]) + assert attrs["gen_ai.operation.name"] == "invoke_agent" + assert attrs["gen_ai.system"] == "openai" + assert attrs["gen_ai.is_stream"] is True + assert attrs["gen_ai.agent.name"] == "asst" + + def test_zero_tokens_are_skipped(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + tmetrics.report_invoke_agent( + ctx, + duration_s=1.0, + ttft_s=0.1, + input_tokens=0, + output_tokens=0, + is_stream=False, + ) + metrics = _collect(reader) + assert "gen_ai.usage.input_tokens" not in metrics + assert "gen_ai.usage.output_tokens" not in metrics + + def test_partial_tokens_are_independently_skipped(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + tmetrics.report_invoke_agent( + ctx, + duration_s=1.0, + ttft_s=0.1, + input_tokens=120, + output_tokens=0, + is_stream=False, + ) + metrics = _collect(reader) + assert metrics["gen_ai.usage.input_tokens"][0].sum == 120 + assert "gen_ai.usage.output_tokens" not in metrics + + def test_error_type_propagates(self, reader_provider): + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + tmetrics.report_invoke_agent( + ctx, + duration_s=0.1, + ttft_s=0.1, + input_tokens=0, + output_tokens=0, + is_stream=True, + error_type="cancelled", + ) + attrs = _attrs(_collect(reader)["gen_ai.request_cnt"][0]) + assert attrs["error.type"] == "cancelled" + + +class TestOperationRouting: + + def test_three_operations_create_three_separate_streams(self, reader_provider): + """Same counter, different attrs -> three independent time series.""" + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + + tmetrics.report_invoke_agent( + ctx, + duration_s=1.0, + ttft_s=0.1, + input_tokens=0, + output_tokens=0, + is_stream=True, + ) + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + _StubLlmResponse(model="gpt-4"), + duration_s=0.5, + ttft_s=0.1, + is_stream=True, + ) + tmetrics.report_execute_tool(ctx, _StubTool("calc"), duration_s=0.2) + + request_cnt_dps = _collect(reader)["gen_ai.request_cnt"] + ops = sorted(_attrs(dp)["gen_ai.operation.name"] for dp in request_cnt_dps) + assert ops == ["chat", "execute_tool", "invoke_agent"] + for dp in request_cnt_dps: + assert dp.value == 1 + + def test_repeated_same_operation_aggregates(self, reader_provider): + """Identical attrs -> single time series with summed value.""" + reader, _ = reader_provider + ctx = _make_ctx(agent_model="gpt-4") + for _ in range(3): + tmetrics.report_call_llm( + ctx, + _StubLlmRequest("gpt-4"), + _StubLlmResponse(model="gpt-4"), + duration_s=0.1, + ttft_s=0.05, + is_stream=True, + ) + dps = _collect(reader)["gen_ai.request_cnt"] + assert len(dps) == 1 + assert dps[0].value == 3 diff --git a/tests/telemetry/test_trace.py b/tests/telemetry/test_trace.py new file mode 100644 index 000000000..27cf2a35b --- /dev/null +++ b/tests/telemetry/test_trace.py @@ -0,0 +1,1181 @@ +# 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 trpc_agent_sdk.telemetry._trace. + +Covers: +- set_trpc_agent_span_name / get_trpc_agent_span_name +- _safe_json_serialize +- trace_runner (with/without message, last_event, state) +- trace_cancellation (with/without partial_text, last_event, branch, state) +- trace_agent (with/without session, user_content, state) +- trace_tool_call (with/without function_response, BaseModel response, state) +- trace_merged_tool_calls (with/without serializable event, state) +- trace_call_llm (with/without usage_metadata, instruction_metadata) +- _build_llm_request_for_trace (filtering inline_data parts) +""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from trpc_agent_sdk.telemetry._trace import ( + _build_llm_request_for_trace, + _safe_json_serialize, + get_trpc_agent_span_name, + set_trpc_agent_span_name, + trace_agent, + trace_call_llm, + trace_cancellation, + trace_merged_tool_calls, + trace_runner, + trace_tool_call, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mock_span(): + """Return a MagicMock that acts as an OpenTelemetry span.""" + span = MagicMock() + span.set_attribute = MagicMock() + span.set_status = MagicMock() + return span + + +def _make_part(text=None, function_call=None, function_response=None, inline_data=None): + part = MagicMock() + part.text = text + part.function_call = function_call + part.function_response = function_response + part.inline_data = inline_data + return part + + +def _make_content(parts=None, role="user"): + content = MagicMock() + content.parts = parts or [] + content.role = role + return content + + +def _make_invocation_context(agent_name="test_agent", + session_id="sess-1", + user_id="user-1", + user_content=None, + branch=None, + invocation_id="inv-1", + session=None): + ctx = MagicMock() + ctx.agent = MagicMock() + ctx.agent.name = agent_name + ctx.invocation_id = invocation_id + ctx.branch = branch + ctx.user_content = user_content + if session is None: + ctx.session = MagicMock() + ctx.session.id = session_id + ctx.session.user_id = user_id + else: + ctx.session = session + return ctx + + +def _make_event(content=None, event_id="evt-1", error_message=None, partial=False): + event = MagicMock() + event.content = content + event.id = event_id + event.error_message = error_message + event.partial = partial + return event + + +def _make_function_response(resp_id="fc-1", response=None): + fr = MagicMock() + fr.id = resp_id + fr.response = response if response is not None else {"result": "ok"} + return fr + + +# --------------------------------------------------------------------------- +# Tests: set/get span name +# --------------------------------------------------------------------------- + + +class TestSpanName: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + def test_get_default_span_name(self): + assert get_trpc_agent_span_name() == "trpc.python.agent" + + def test_set_and_get_span_name(self): + set_trpc_agent_span_name("custom.span") + assert get_trpc_agent_span_name() == "custom.span" + + def test_set_empty_span_name(self): + set_trpc_agent_span_name("") + assert get_trpc_agent_span_name() == "" + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: _safe_json_serialize +# --------------------------------------------------------------------------- + + +class TestSafeJsonSerialize: + + def test_serialize_dict(self): + result = _safe_json_serialize({"key": "value"}) + assert json.loads(result) == {"key": "value"} + + def test_serialize_list(self): + result = _safe_json_serialize([1, 2, 3]) + assert json.loads(result) == [1, 2, 3] + + def test_serialize_string(self): + result = _safe_json_serialize("hello") + assert json.loads(result) == "hello" + + def test_serialize_number(self): + result = _safe_json_serialize(42) + assert json.loads(result) == 42 + + def test_serialize_none(self): + result = _safe_json_serialize(None) + assert result == "null" + + def test_serialize_non_serializable_field(self): + result = _safe_json_serialize({"fn": lambda x: x}) + parsed = json.loads(result) + assert parsed["fn"] == "" + + def test_serialize_unicode(self): + result = _safe_json_serialize({"msg": "你好世界"}) + assert "你好世界" in result + + def test_serialize_nested(self): + obj = {"a": {"b": [1, {"c": True}]}} + result = _safe_json_serialize(obj) + assert json.loads(result) == obj + + def test_serialize_bool(self): + assert json.loads(_safe_json_serialize(True)) is True + + def test_serialize_empty_dict(self): + assert json.loads(_safe_json_serialize({})) == {} + + +# --------------------------------------------------------------------------- +# Tests: trace_runner +# --------------------------------------------------------------------------- + + +class TestTraceRunner: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_attributes(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_runner( + app_name="myapp", + user_id="u1", + session_id="s1", + invocation_context=ctx, + ) + + span.set_attribute.assert_any_call("gen_ai.system", "trpc.python.agent") + span.set_attribute.assert_any_call("gen_ai.operation.name", "run_runner") + span.set_attribute.assert_any_call("trpc.python.agent.runner.app_name", "myapp") + span.set_attribute.assert_any_call("trpc.python.agent.runner.user_id", "u1") + span.set_attribute.assert_any_call("trpc.python.agent.runner.session_id", "s1") + span.set_attribute.assert_any_call( + "trpc.python.agent.runner.name", + "[trpc-agent]: myapp/test_agent", + ) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_new_message(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + parts = [_make_part(text="hello"), _make_part(text="world")] + content = _make_content(parts=parts) + + trace_runner( + app_name="app", + user_id="u", + session_id="s", + invocation_context=ctx, + new_message=content, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "hello\nworld") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_none_text_parts(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + parts = [_make_part(text=None), _make_part(text="ok")] + content = _make_content(parts=parts) + + trace_runner( + app_name="app", + user_id="u", + session_id="s", + invocation_context=ctx, + new_message=content, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "\nok") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_last_event(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + event_parts = [_make_part(text="response")] + event_content = _make_content(parts=event_parts) + last_event = _make_event(content=event_content) + + trace_runner( + app_name="app", + user_id="u", + session_id="s", + invocation_context=ctx, + last_event=last_event, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "response") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_empty_input_output_without_message_and_event(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_runner("app", "u", "s", ctx) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "") + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_state_begin_and_end(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_runner("app", "u", "s", ctx, state_begin={"k": 1}, state_end={"k": 2}) + + span.set_attribute.assert_any_call("trpc.python.agent.state.begin", _safe_json_serialize({"k": 1})) + span.set_attribute.assert_any_call("trpc.python.agent.state.end", _safe_json_serialize({"k": 2})) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_state_none_not_set(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_runner("app", "u", "s", ctx) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.state.begin" not in attr_keys + assert "trpc.python.agent.state.end" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_content_no_parts(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + content = _make_content(parts=[]) + trace_runner("app", "u", "s", ctx, new_message=content) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_uses_custom_span_name(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + set_trpc_agent_span_name("my.custom") + trace_runner("app", "u", "s", ctx) + + span.set_attribute.assert_any_call("gen_ai.system", "my.custom") + span.set_attribute.assert_any_call("my.custom.runner.app_name", "app") + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: trace_cancellation +# --------------------------------------------------------------------------- + + +class TestTraceCancellation: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_cancellation(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_cancellation( + app_name="app", + user_id="u", + session_id="s", + invocation_context=ctx, + reason="user_cancelled", + ) + + from opentelemetry import trace as ot_trace + span.set_status.assert_called_once_with(ot_trace.StatusCode.ERROR, "user_cancelled") + span.set_attribute.assert_any_call("gen_ai.operation.name", "run_runner_cancelled") + span.set_attribute.assert_any_call("trpc.python.agent.cancellation.reason", "user_cancelled") + span.set_attribute.assert_any_call("trpc.python.agent.cancellation.agent_name", "test_agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_partial_text(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_cancellation("app", "u", "s", ctx, reason="timeout", partial_text="partial output") + + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "[CANCELLED]\npartial output") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_last_event_no_partial(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + event_parts = [_make_part(text="event text")] + event_content = _make_content(parts=event_parts) + last_event = _make_event(content=event_content) + + trace_cancellation("app", "u", "s", ctx, reason="err", last_event=last_event) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "[CANCELLED]\nevent text") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_partial_text_takes_priority_over_last_event(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + event_parts = [_make_part(text="event text")] + event_content = _make_content(parts=event_parts) + last_event = _make_event(content=event_content) + + trace_cancellation( + "app", + "u", + "s", + ctx, + reason="err", + partial_text="winner", + last_event=last_event, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "[CANCELLED]\nwinner") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_branch(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context(branch="agent_a.agent_b") + + trace_cancellation("app", "u", "s", ctx, reason="cancel") + + span.set_attribute.assert_any_call("trpc.python.agent.cancellation.branch", "agent_a.agent_b") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_no_branch(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context(branch=None) + + trace_cancellation("app", "u", "s", ctx, reason="cancel") + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.cancellation.branch" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_state_begin_and_partial(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_cancellation( + "app", + "u", + "s", + ctx, + reason="cancel", + state_begin={"a": 1}, + state_partial={"a": 2}, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.state.begin", _safe_json_serialize({"a": 1})) + span.set_attribute.assert_any_call("trpc.python.agent.state.partial", _safe_json_serialize({"a": 2})) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_state_none_not_set(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_cancellation("app", "u", "s", ctx, reason="cancel") + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.state.begin" not in attr_keys + assert "trpc.python.agent.state.partial" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_new_message(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + parts = [_make_part(text="user input")] + content = _make_content(parts=parts) + + trace_cancellation("app", "u", "s", ctx, reason="cancel", new_message=content) + + span.set_attribute.assert_any_call("trpc.python.agent.runner.input", "user input") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_cancelled_output_no_partial_no_event(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_cancellation("app", "u", "s", ctx, reason="cancel") + + span.set_attribute.assert_any_call("trpc.python.agent.runner.output", "[CANCELLED]\n") + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: trace_agent +# --------------------------------------------------------------------------- + + +class TestTraceAgent: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_agent_trace(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_agent(ctx, agent_action="called tool X") + + span.set_attribute.assert_any_call("gen_ai.system", "trpc.python.agent") + span.set_attribute.assert_any_call("gen_ai.operation.name", "run_agent") + span.set_attribute.assert_any_call("trpc.python.agent.agent.name", "test_agent") + span.set_attribute.assert_any_call("trpc.python.agent.agent.output", "called tool X") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_session(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context(session_id="s-123", user_id="u-456") + + trace_agent(ctx) + + span.set_attribute.assert_any_call("trpc.python.agent.agent.session_id", "s-123") + span.set_attribute.assert_any_call("trpc.python.agent.agent.user_id", "u-456") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_without_session(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context(session=None) + ctx.session = None + + trace_agent(ctx) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.agent.session_id" not in attr_keys + assert "trpc.python.agent.agent.user_id" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_user_content(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + parts = [_make_part(text="hello"), _make_part(text=" agent")] + user_content = _make_content(parts=parts) + ctx = _make_invocation_context(user_content=user_content) + + trace_agent(ctx) + + span.set_attribute.assert_any_call("trpc.python.agent.agent.input", "hello\n agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_without_user_content(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context(user_content=None) + + trace_agent(ctx) + + span.set_attribute.assert_any_call("trpc.python.agent.agent.input", "") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_state(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_agent(ctx, state_begin={"x": 1}, state_end={"x": 2}) + + span.set_attribute.assert_any_call("trpc.python.agent.state.begin", _safe_json_serialize({"x": 1})) + span.set_attribute.assert_any_call("trpc.python.agent.state.end", _safe_json_serialize({"x": 2})) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_default_empty_action(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + trace_agent(ctx) + + span.set_attribute.assert_any_call("trpc.python.agent.agent.output", "") + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: trace_tool_call +# --------------------------------------------------------------------------- + + +class TestTraceToolCall: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_tool_call(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "my_tool" + tool.description = "A test tool" + + func_resp = _make_function_response(resp_id="fc-1", response={"output": "data"}) + part = _make_part(function_response=func_resp) + content = _make_content(parts=[part]) + event = _make_event(content=content, event_id="e-1") + + trace_tool_call(tool=tool, args={"input": "val"}, function_response_event=event) + + span.set_attribute.assert_any_call("gen_ai.system", "trpc.python.agent") + span.set_attribute.assert_any_call("gen_ai.operation.name", "execute_tool") + span.set_attribute.assert_any_call("gen_ai.tool.name", "my_tool") + span.set_attribute.assert_any_call("gen_ai.tool.description", "A test tool") + span.set_attribute.assert_any_call("gen_ai.tool.call.id", "fc-1") + span.set_attribute.assert_any_call("trpc.python.agent.event_id", "e-1") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_tool_call_no_function_response(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "t" + tool.description = "d" + + part = _make_part(function_response=None) + content = _make_content(parts=[part]) + event = _make_event(content=content) + + trace_tool_call(tool=tool, args={}, function_response_event=event) + + span.set_attribute.assert_any_call("gen_ai.tool.call.id", "") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_tool_response_not_dict(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "t" + tool.description = "d" + + func_resp = _make_function_response(response="plain string") + part = _make_part(function_response=func_resp) + content = _make_content(parts=[part]) + event = _make_event(content=content) + + trace_tool_call(tool=tool, args={}, function_response_event=event) + + # Should wrap in {"result": ...} + tool_resp_calls = [ + c for c in span.set_attribute.call_args_list if c.args[0] == "trpc.python.agent.tool_response" + ] + assert len(tool_resp_calls) == 1 + parsed = json.loads(tool_resp_calls[0].args[1]) + assert parsed["result"] == "plain string" + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_tool_response_with_base_model_value(self, mock_get_span): + from pydantic import BaseModel as PydanticBaseModel + + class MyModel(PydanticBaseModel): + field: str = "value" + + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "t" + tool.description = "d" + + model_instance = MyModel() + func_resp = _make_function_response(response={"model": model_instance}) + part = _make_part(function_response=func_resp) + content = _make_content(parts=[part]) + event = _make_event(content=content) + + trace_tool_call(tool=tool, args={}, function_response_event=event) + + tool_resp_calls = [ + c for c in span.set_attribute.call_args_list if c.args[0] == "trpc.python.agent.tool_response" + ] + assert len(tool_resp_calls) == 1 + parsed = json.loads(tool_resp_calls[0].args[1]) + assert json.loads(parsed["model"]) == {"field": "value"} + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_sets_empty_llm_request_and_response(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "t" + tool.description = "d" + + func_resp = _make_function_response() + part = _make_part(function_response=func_resp) + content = _make_content(parts=[part]) + event = _make_event(content=content) + + trace_tool_call(tool=tool, args={}, function_response_event=event) + + span.set_attribute.assert_any_call("trpc.python.agent.llm_request", "{}") + span.set_attribute.assert_any_call("trpc.python.agent.llm_response", "{}") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_state(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + tool = MagicMock() + tool.name = "t" + tool.description = "d" + + func_resp = _make_function_response() + part = _make_part(function_response=func_resp) + content = _make_content(parts=[part]) + event = _make_event(content=content) + + trace_tool_call( + tool=tool, + args={}, + function_response_event=event, + state_begin={"s": 0}, + state_end={"s": 1}, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.state.begin", _safe_json_serialize({"s": 0})) + span.set_attribute.assert_any_call("trpc.python.agent.state.end", _safe_json_serialize({"s": 1})) + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: trace_merged_tool_calls +# --------------------------------------------------------------------------- + + +class TestTraceMergedToolCalls: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_merged(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + event = MagicMock() + event.model_dumps_json = MagicMock(return_value='{"merged": true}') + + trace_merged_tool_calls(response_event_id="re-1", function_response_event=event) + + span.set_attribute.assert_any_call("gen_ai.system", "trpc.python.agent") + span.set_attribute.assert_any_call("gen_ai.operation.name", "execute_tool") + span.set_attribute.assert_any_call("gen_ai.tool.name", "(merged tools)") + span.set_attribute.assert_any_call("gen_ai.tool.description", "(merged tools)") + span.set_attribute.assert_any_call("gen_ai.tool.call.id", "re-1") + span.set_attribute.assert_any_call("trpc.python.agent.event_id", "re-1") + span.set_attribute.assert_any_call("trpc.python.agent.tool_call_args", "N/A") + span.set_attribute.assert_any_call("trpc.python.agent.tool_response", '{"merged": true}') + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_non_serializable_event(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + event = MagicMock() + event.model_dumps_json = MagicMock(side_effect=TypeError("cannot serialize")) + + trace_merged_tool_calls(response_event_id="re-1", function_response_event=event) + + span.set_attribute.assert_any_call("trpc.python.agent.tool_response", "") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_sets_empty_llm_request_and_response(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + event = MagicMock() + event.model_dumps_json = MagicMock(return_value='{}') + + trace_merged_tool_calls(response_event_id="re-1", function_response_event=event) + + span.set_attribute.assert_any_call("trpc.python.agent.llm_request", "{}") + span.set_attribute.assert_any_call("trpc.python.agent.llm_response", "{}") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_state(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + event = MagicMock() + event.model_dumps_json = MagicMock(return_value='{}') + + trace_merged_tool_calls( + response_event_id="re-1", + function_response_event=event, + state_begin={"a": 1}, + state_end={"a": 2}, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.state.begin", _safe_json_serialize({"a": 1})) + span.set_attribute.assert_any_call("trpc.python.agent.state.end", _safe_json_serialize({"a": 2})) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_state_none_not_set(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + + event = MagicMock() + event.model_dumps_json = MagicMock(return_value='{}') + + trace_merged_tool_calls(response_event_id="re-1", function_response_event=event) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.state.begin" not in attr_keys + assert "trpc.python.agent.state.end" not in attr_keys + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: trace_call_llm +# --------------------------------------------------------------------------- + + +class TestTraceCallLlm: + + def setup_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + def _make_llm_request(self, model="test-model", contents=None): + req = MagicMock() + req.model = model + req.contents = contents or [] + req.config = MagicMock() + req.config.model_dump = MagicMock(return_value={"temperature": 0.7}) + return req + + def _make_llm_response(self, content=None, usage=None, error_message=None): + resp = MagicMock() + resp.content = content + resp.error_message = error_message + resp.model_dump_json = MagicMock(return_value='{"content": "response"}') + resp.usage_metadata = usage + return resp + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_basic_llm_trace(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + req = self._make_llm_request() + resp = self._make_llm_response() + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + span.set_attribute.assert_any_call("gen_ai.system", "trpc.python.agent") + span.set_attribute.assert_any_call("gen_ai.operation.name", "call_llm") + span.set_attribute.assert_any_call("gen_ai.request.model", "test-model") + span.set_attribute.assert_any_call("trpc.python.agent.invocation_id", "inv-1") + span.set_attribute.assert_any_call("trpc.python.agent.session_id", "sess-1") + span.set_attribute.assert_any_call("trpc.python.agent.event_id", "e-1") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_usage_metadata(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 100 + usage.total_token_count = 250 + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 100) + span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 150) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_without_usage_metadata(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + req = self._make_llm_request() + resp = self._make_llm_response(usage=None) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "gen_ai.usage.input_tokens" not in attr_keys + assert "gen_ai.usage.output_tokens" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_usage_metadata_with_zero_prompt(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 0 + usage.total_token_count = 0 + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "gen_ai.usage.input_tokens" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_with_instruction_metadata(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + from trpc_agent_sdk.types import InstructionMetadata + metadata = InstructionMetadata(name="my_instruction", version=3, labels=["prod", "v2"]) + + req = self._make_llm_request() + resp = self._make_llm_response() + + trace_call_llm( + ctx, + event_id="e-1", + llm_request=req, + llm_response=resp, + instruction_metadata=metadata, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.instruction.name", "my_instruction") + span.set_attribute.assert_any_call("trpc.python.agent.instruction.version", 3) + span.set_attribute.assert_any_call("trpc.python.agent.instruction.labels", "prod,v2") + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_without_instruction_metadata(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + req = self._make_llm_request() + resp = self._make_llm_response() + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "trpc.python.agent.instruction.name" not in attr_keys + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_non_serializable_llm_response(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + req = self._make_llm_request() + resp = self._make_llm_response() + resp.model_dump_json = MagicMock(side_effect=Exception("serialize error")) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + llm_resp_calls = [c for c in span.set_attribute.call_args_list if c.args[0] == "trpc.python.agent.llm_response"] + assert len(llm_resp_calls) == 1 + assert llm_resp_calls[0].args[1] == "" + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_instruction_metadata_empty_labels(self, mock_get_span): + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + from trpc_agent_sdk.types import InstructionMetadata + metadata = InstructionMetadata(name="inst", version=1, labels=[]) + + req = self._make_llm_request() + resp = self._make_llm_response() + + trace_call_llm( + ctx, + event_id="e-1", + llm_request=req, + llm_response=resp, + instruction_metadata=metadata, + ) + + span.set_attribute.assert_any_call("trpc.python.agent.instruction.labels", "") + + # --- prompt cache token span attributes -------------------------------- + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_cache_read_input_tokens_attribute_set_when_present(self, mock_get_span): + """cache_read_input_tokens is written as a span attribute when the field is not None.""" + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 1000 + usage.total_token_count = 1050 + usage.cache_read_input_tokens = 800 + usage.cache_creation_input_tokens = None + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + span.set_attribute.assert_any_call("gen_ai.usage.cache_read_input_tokens", 800) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_cache_creation_input_tokens_attribute_set_when_present(self, mock_get_span): + """cache_creation_input_tokens is written as a span attribute when the field is not None.""" + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 1000 + usage.total_token_count = 1100 + usage.cache_read_input_tokens = None + usage.cache_creation_input_tokens = 200 + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + span.set_attribute.assert_any_call("gen_ai.usage.cache_creation_input_tokens", 200) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_both_cache_token_attributes_set(self, mock_get_span): + """Both cache span attributes are emitted when both fields are non-None.""" + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 1200 + usage.total_token_count = 1250 + usage.cache_read_input_tokens = 900 + usage.cache_creation_input_tokens = 300 + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + span.set_attribute.assert_any_call("gen_ai.usage.cache_read_input_tokens", 900) + span.set_attribute.assert_any_call("gen_ai.usage.cache_creation_input_tokens", 300) + + @patch("trpc_agent_sdk.telemetry._trace.trace.get_current_span") + def test_cache_token_attributes_absent_when_none(self, mock_get_span): + """Neither cache attribute is emitted when both fields are None.""" + span = _mock_span() + mock_get_span.return_value = span + ctx = _make_invocation_context() + + usage = MagicMock() + usage.prompt_token_count = 100 + usage.total_token_count = 150 + usage.cache_read_input_tokens = None + usage.cache_creation_input_tokens = None + + req = self._make_llm_request() + resp = self._make_llm_response(usage=usage) + + trace_call_llm(ctx, event_id="e-1", llm_request=req, llm_response=resp) + + attr_keys = [call.args[0] for call in span.set_attribute.call_args_list] + assert "gen_ai.usage.cache_read_input_tokens" not in attr_keys + assert "gen_ai.usage.cache_creation_input_tokens" not in attr_keys + + def teardown_method(self): + set_trpc_agent_span_name("trpc.python.agent") + + +# --------------------------------------------------------------------------- +# Tests: _build_llm_request_for_trace +# --------------------------------------------------------------------------- + + +class TestBuildLlmRequestForTrace: + + def test_basic_build(self): + content = MagicMock() + content.role = "user" + part_text = MagicMock() + part_text.inline_data = None + part_text.text = "hello" + content.parts = [part_text] + + req = MagicMock() + req.model = "gpt-4" + req.config = MagicMock() + req.config.model_dump = MagicMock(return_value={"temperature": 0.5}) + req.contents = [content] + + with patch("trpc_agent_sdk.telemetry._trace.Content") as MockContent: + mock_content_instance = MagicMock() + mock_content_instance.model_dump = MagicMock(return_value={"role": "user", "parts": [{"text": "hello"}]}) + MockContent.return_value = mock_content_instance + + result = _build_llm_request_for_trace(req) + + assert result["model"] == "gpt-4" + assert result["config"] == {"temperature": 0.5} + assert len(result["contents"]) == 1 + + def test_filters_inline_data_parts(self): + content = MagicMock() + content.role = "user" + + part_text = MagicMock() + part_text.inline_data = None + + part_image = MagicMock() + part_image.inline_data = b"image_bytes" + + content.parts = [part_text, part_image] + + req = MagicMock() + req.model = "model" + req.config = MagicMock() + req.config.model_dump = MagicMock(return_value={}) + req.contents = [content] + + with patch("trpc_agent_sdk.telemetry._trace.Content") as MockContent: + mock_content_instance = MagicMock() + mock_content_instance.model_dump = MagicMock(return_value={"role": "user", "parts": [{"text": "t"}]}) + MockContent.return_value = mock_content_instance + + result = _build_llm_request_for_trace(req) + + MockContent.assert_called_once() + call_kwargs = MockContent.call_args + passed_parts = call_kwargs.kwargs.get("parts") or call_kwargs[1].get("parts", []) + if not passed_parts: + passed_parts = call_kwargs[0][1] if len(call_kwargs[0]) > 1 else [] + assert part_image not in (passed_parts if passed_parts else []) + + def test_multiple_contents(self): + contents = [] + for role in ["user", "assistant"]: + c = MagicMock() + c.role = role + p = MagicMock() + p.inline_data = None + c.parts = [p] + contents.append(c) + + req = MagicMock() + req.model = "model" + req.config = MagicMock() + req.config.model_dump = MagicMock(return_value={}) + req.contents = contents + + with patch("trpc_agent_sdk.telemetry._trace.Content") as MockContent: + mock_inst = MagicMock() + mock_inst.model_dump = MagicMock(return_value={"role": "x", "parts": []}) + MockContent.return_value = mock_inst + + result = _build_llm_request_for_trace(req) + + assert len(result["contents"]) == 2 + + def test_empty_contents(self): + req = MagicMock() + req.model = "model" + req.config = MagicMock() + req.config.model_dump = MagicMock(return_value={}) + req.contents = [] + + result = _build_llm_request_for_trace(req) + + assert result["contents"] == [] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..04b1e8f26 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,98 @@ +# 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 _cli module.""" + +import pytest + +from trpc_agent_sdk._cli import ( + _derive_command_path_from_module, + _normalize_command_path, + register_cli, + _REGISTERED_MODULES, + _REGISTRATIONS, + main, +) + + +class TestDeriveCommandPath: + """Test suite for _derive_command_path_from_module.""" + + def test_simple_module(self): + """Test simple module path derivation.""" + result = _derive_command_path_from_module("trpc_agent_sdk.code_executors._cli") + assert result == ("code-executors",) + + def test_nested_module(self): + """Test nested module path derivation.""" + result = _derive_command_path_from_module("trpc_agent_sdk.code_executors.container._cli") + assert result == ("code-executors", "container") + + def test_no_prefix(self): + """Test module without package prefix.""" + result = _derive_command_path_from_module("my_module._cli") + assert result == ("my-module",) + + def test_empty_parts_raises(self): + """Test empty derived path raises ValueError.""" + with pytest.raises(ValueError, match="Cannot derive"): + _derive_command_path_from_module("trpc_agent_sdk.__init__") + + +class TestNormalizeCommandPath: + """Test suite for _normalize_command_path.""" + + def test_strips_whitespace(self): + """Test segments are stripped.""" + result = _normalize_command_path([" code ", " exec "]) + assert result == ("code", "exec") + + def test_empty_raises(self): + """Test empty path raises ValueError.""" + with pytest.raises(ValueError, match="at least one"): + _normalize_command_path([]) + + def test_blank_segments_filtered(self): + """Test blank segments are filtered.""" + with pytest.raises(ValueError): + _normalize_command_path(["", " "]) + + +class TestRegisterCli: + """Test suite for register_cli.""" + + def test_registers_module(self): + """Test register_cli adds a registration.""" + test_module = "test_module_for_cli_test_12345" + initial_count = len(_REGISTRATIONS) + register_cli(test_module, command_path=["test"]) + assert test_module in _REGISTERED_MODULES + assert len(_REGISTRATIONS) > initial_count + _REGISTERED_MODULES.discard(test_module) + _REGISTRATIONS.pop() + + def test_duplicate_ignored(self): + """Test duplicate registration is ignored.""" + test_module = "test_module_dup_cli_12345" + register_cli(test_module, command_path=["test"]) + count = len(_REGISTRATIONS) + register_cli(test_module, command_path=["test2"]) + assert len(_REGISTRATIONS) == count + _REGISTERED_MODULES.discard(test_module) + _REGISTRATIONS.pop() + + +class TestMain: + """Test suite for main().""" + + def test_help_returns_zero(self): + """Test --help returns 0.""" + result = main(["--help"]) + assert result == 0 + + def test_no_args_shows_help(self): + """Test no args shows help (exits with 0 or 2).""" + result = main([]) + assert result in (0, 2) diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 000000000..a9a1a1a03 --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,818 @@ +# 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 the Runner class. + +This test suite focuses on the core functionality of the Runner class, +including: +- Agent execution and event generation +- Session management and event appending +- Agent transfer functionality +- Human-in-the-loop scenarios +- Toolset cleanup +- Error handling +""" + +import asyncio +from typing import AsyncGenerator +from unittest.mock import ANY +from unittest.mock import AsyncMock +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.artifacts import BaseArtifactService +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.memory import BaseMemoryService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + + +# Fixtures +@pytest.fixture +def mock_session_service(): + """Mock session service.""" + service = AsyncMock(spec=BaseSessionService) + service.get_session = AsyncMock() + service.create_session = AsyncMock() + service.append_event = AsyncMock() + service.create_session_summary = AsyncMock() + service.close = AsyncMock() + return service + + +@pytest.fixture +def mock_agent(): + """Mock base agent.""" + agent = Mock(spec=BaseAgent) + agent.name = "test_agent" + agent.sub_agents = [] + agent.get_subagents = Mock(return_value=[]) + agent.parent_agent = None + agent.run_async = AsyncMock() + agent.find_agent = Mock(return_value=None) + return agent + + +@pytest.fixture +def mock_session(): + """Mock session object.""" + session = Session( + id="test_session_id", + app_name="test_app", + user_id="test_user", + save_key="test_save_key", + state={}, + events=[], + conversation_count=0, + last_update_time=0.0, + ) + return session + + +@pytest.fixture +def runner(mock_agent, mock_session_service): + """Create a Runner instance with mocked dependencies.""" + return Runner( + app_name="test_app", + agent=mock_agent, + session_service=mock_session_service, + ) + + +# Tests for Runner initialization +class TestRunnerInit: + """Tests for Runner initialization.""" + + def test_init_with_required_params(self, mock_agent, mock_session_service): + """Test Runner initialization with required parameters.""" + runner = Runner( + app_name="test_app", + agent=mock_agent, + session_service=mock_session_service, + ) + + assert runner.app_name == "test_app" + assert runner.agent == mock_agent + assert runner.session_service == mock_session_service + assert runner.artifact_service is None + assert runner.memory_service is None + assert runner._close_session_service_on_close is True + assert runner._close_memory_service_on_close is True + + def test_init_with_all_params(self, mock_agent, mock_session_service): + """Test Runner initialization with all parameters.""" + artifact_service = Mock(spec=BaseArtifactService) + memory_service = Mock(spec=BaseMemoryService) + + runner = Runner( + app_name="test_app", + agent=mock_agent, + session_service=mock_session_service, + artifact_service=artifact_service, + memory_service=memory_service, + close_session_service_on_close=False, + close_memory_service_on_close=False, + ) + + assert runner.artifact_service == artifact_service + assert runner.memory_service == memory_service + assert runner._close_session_service_on_close is False + assert runner._close_memory_service_on_close is False + + +# Tests for run_async method +class TestRunAsync: + """Tests for the main run_async method.""" + + @pytest.mark.asyncio + async def test_run_async_creates_new_session_when_not_found(self, runner, mock_session_service, mock_agent, + mock_session): + """Test that run_async creates a new session when none exists.""" + # Setup + mock_session_service.get_session.return_value = None + mock_session_service.create_session.return_value = mock_session + + # Mock agent to return a simple event + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + events.append(event) + + # Verify + mock_session_service.get_session.assert_called_once() + mock_session_service.create_session.assert_called_once_with( + app_name="test_app", + user_id="test_user", + session_id="test_session", + agent_context=ANY, + ) + assert len(events) == 1 + assert events[0].author == "test_agent" + + @pytest.mark.asyncio + async def test_run_async_uses_existing_session(self, runner, mock_session_service, mock_agent, mock_session): + """Test that run_async uses an existing session.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + events.append(event) + + # Verify + mock_session_service.get_session.assert_called_once() + mock_session_service.create_session.assert_not_called() + assert len(events) == 1 + + @pytest.mark.asyncio + async def test_run_async_appends_user_message(self, runner, mock_session_service, mock_agent, mock_session): + """Test that user messages are appended to the session.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + user_message = Content(parts=[Part(text="Hello")]) + + # Execute + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=user_message, + ): + pass + + # Verify that append_event was called for user message + calls = mock_session_service.append_event.call_args_list + user_event_call = [call for call in calls if call[1]['event'].author == 'user'] + assert len(user_event_call) >= 1 + assert user_event_call[0][1]['event'].content == user_message + + @pytest.mark.asyncio + async def test_run_async_streaming_mode(self, runner, mock_session_service, mock_agent, mock_session): + """Test streaming mode yields partial events.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + # Yield partial events + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Partial")]), + partial=True, + ) + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Complete")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute with streaming enabled + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + run_config=RunConfig(streaming=True), + ): + events.append(event) + + # Verify - should receive both partial and complete events + assert len(events) == 2 + assert events[0].partial is True + assert events[1].partial is False + + @pytest.mark.asyncio + async def test_run_async_non_streaming_mode(self, runner, mock_session_service, mock_agent, mock_session): + """Test non-streaming mode only yields complete events.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + # Yield partial events + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Partial")]), + partial=True, + ) + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Complete")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute with streaming disabled + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + run_config=RunConfig(streaming=False), + ): + events.append(event) + + # Verify - should only receive complete events + assert len(events) == 1 + assert events[0].partial is False + + @pytest.mark.asyncio + async def test_run_async_handles_empty_message(self, runner, mock_session_service, mock_agent, mock_session): + """Test handling of messages with no parts.""" + # Setup + mock_session_service.get_session.return_value = mock_session + empty_message = Content(parts=[]) + + # Execute and expect ValueError + with pytest.raises(ValueError, match="No parts in the new_message"): + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=empty_message, + ): + pass + + @pytest.mark.asyncio + async def test_run_async_increments_conversation_count(self, runner, mock_session_service, mock_agent, + mock_session): + """Test that conversation count is incremented.""" + # Setup + initial_count = mock_session.conversation_count + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + pass + + # Verify + assert mock_session.conversation_count == initial_count + 1 + + +# Tests for agent transfer functionality +class TestAgentTransfer: + """Tests for agent transfer functionality.""" + + @pytest.mark.asyncio + async def test_agent_transfer_to_sub_agent(self, runner, mock_session_service, mock_agent, mock_session): + """Test successful transfer from root agent to sub-agent.""" + # Setup sub-agent + sub_agent = Mock(spec=BaseAgent) + sub_agent.name = "sub_agent" + sub_agent.parent_agent = mock_agent + mock_agent.find_agent = Mock(return_value=sub_agent) + + mock_session_service.get_session.return_value = mock_session + + # Mock agent run with transfer request + async def mock_agent_run(ctx): + if ctx.agent.name == "test_agent": + # Root agent requests transfer + event = Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Transferring")]), + partial=False, + ) + event.actions = EventActions(transfer_to_agent="sub_agent") + yield event + + async def mock_sub_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="sub_agent", + content=Content(parts=[Part(text="Sub-agent response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + sub_agent.run_async = mock_sub_agent_run + + # Execute + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + events.append(event) + + # Verify + assert len(events) == 2 + assert events[0].author == "test_agent" + assert events[1].author == "sub_agent" + mock_agent.find_agent.assert_called_with("sub_agent") + + @pytest.mark.asyncio + async def test_agent_transfer_target_not_found(self, runner, mock_session_service, mock_agent, mock_session): + """Test handling when transfer target agent is not found.""" + # Setup + mock_session_service.get_session.return_value = mock_session + mock_agent.find_agent = Mock(return_value=None) + + async def mock_agent_run(ctx): + event = Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Transferring")]), + partial=False, + ) + event.actions = EventActions(transfer_to_agent="nonexistent_agent") + yield event + + mock_agent.run_async = mock_agent_run + + # Execute + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + events.append(event) + + # Verify error event was generated + error_events = [e for e in events if e.error_code == "transfer_target_not_found"] + assert len(error_events) == 1 + assert "not found" in error_events[0].error_message + + +# Tests for _find_agent_to_run method +class TestFindAgentToRun: + """Tests for the _find_agent_to_run method.""" + + def test_find_agent_returns_root_when_no_events(self, runner, mock_agent, mock_session): + """Test returns root agent when session has no events.""" + result = runner._find_agent_to_run(mock_session, mock_agent, RunConfig()) + assert result == mock_agent + + def test_find_agent_with_start_from_last_agent_disabled(self, runner, mock_agent, mock_session): + """Test returns root agent when start_from_last_agent is disabled.""" + # Add some events + mock_session.events.append( + Event( + invocation_id="inv_1", + author="some_agent", + content=Content(parts=[Part(text="Response")]), + )) + + run_config = RunConfig(start_from_last_agent=False) + result = runner._find_agent_to_run(mock_session, mock_agent, run_config) + assert result == mock_agent + + def test_find_agent_with_human_in_the_loop(self, runner, mock_agent, mock_session): + """Test finds agent when human-in-the-loop function response exists.""" + # Setup: agent made function call + func_call = FunctionCall(id="func_123", name="test_function", args={"arg": "value"}) + agent_event = Event( + invocation_id="inv_1", + author="test_agent", + content=Content(parts=[Part(function_call=func_call)]), + ) + + # User provides function response + func_response = FunctionResponse(id="func_123", name="test_function", response={"result": "success"}) + user_event = Event( + invocation_id="inv_2", + author="user", + content=Content(parts=[Part(function_response=func_response)]), + ) + + mock_session.events.extend([agent_event, user_event]) + + # Execute + result = runner._find_agent_to_run(mock_session, mock_agent, RunConfig()) + + # Verify - should return the agent that made the function call + assert result == mock_agent + + +# Tests for _is_transferable_across_agent_tree method +class TestIsTransferableAcrossAgentTree: + """Tests for _is_transferable_across_agent_tree method.""" + + def test_llm_agent_is_transferable(self, runner): + """Test that LlmAgent is considered transferable.""" + from trpc_agent_sdk.agents._llm_agent import LlmAgent + + llm_agent = Mock(spec=LlmAgent) + llm_agent.disallow_transfer_to_parent = False + llm_agent.parent_agent = None + + result = runner._is_transferable_across_agent_tree(llm_agent) + assert result is True + + def test_non_llm_agent_not_transferable(self, runner, mock_agent): + """Test that non-LlmAgent is not transferable.""" + result = runner._is_transferable_across_agent_tree(mock_agent) + assert result is False + + def test_agent_with_disallow_transfer_not_transferable(self, runner): + """Test that agent with disallow_transfer_to_parent is not transferable.""" + from trpc_agent_sdk.agents._llm_agent import LlmAgent + + llm_agent = Mock(spec=LlmAgent) + llm_agent.disallow_transfer_to_parent = True + llm_agent.parent_agent = None + + result = runner._is_transferable_across_agent_tree(llm_agent) + assert result is False + + +# Tests for _collect_toolset method +class TestCollectToolset: + """Tests for _collect_toolset method.""" + + def test_collect_toolset_from_agent_with_tools(self, runner, mock_agent): + """Test collecting toolsets from agent with tools.""" + from trpc_agent_sdk.tools import BaseToolSet + + toolset1 = Mock(spec=BaseToolSet) + toolset2 = Mock(spec=BaseToolSet) + mock_agent.tools = [toolset1, toolset2] + mock_agent.get_subagents.return_value = [] + + result = runner._collect_toolset(mock_agent) + + assert len(result) == 2 + assert toolset1 in result + assert toolset2 in result + + def test_collect_toolset_from_agent_hierarchy(self, runner, mock_agent): + """Test collecting toolsets from agent hierarchy.""" + from trpc_agent_sdk.tools import BaseToolSet + + # Root agent tools + toolset1 = Mock(spec=BaseToolSet) + mock_agent.tools = [toolset1] + + # Sub-agent tools + sub_agent = Mock(spec=BaseAgent) + toolset2 = Mock(spec=BaseToolSet) + sub_agent.tools = [toolset2] + sub_agent.sub_agents = [] + sub_agent.get_subagents = Mock(return_value=[]) + + mock_agent.sub_agents = [sub_agent] + mock_agent.get_subagents.return_value = [sub_agent] + + result = runner._collect_toolset(mock_agent) + + assert len(result) == 2 + assert toolset1 in result + assert toolset2 in result + + +# Tests for cleanup functionality +class TestCleanup: + """Tests for cleanup and close methods.""" + + @pytest.mark.asyncio + async def test_cleanup_toolsets_success(self, runner): + """Test successful cleanup of toolsets.""" + from trpc_agent_sdk.tools import BaseToolSet + + toolset1 = AsyncMock(spec=BaseToolSet) + toolset2 = AsyncMock(spec=BaseToolSet) + + toolsets = {toolset1, toolset2} + await runner._cleanup_toolsets(toolsets) + + toolset1.close.assert_called_once() + toolset2.close.assert_called_once() + + @pytest.mark.asyncio + async def test_cleanup_toolsets_handles_timeout(self, runner): + """Test cleanup handles toolset timeout gracefully.""" + from trpc_agent_sdk.tools import BaseToolSet + + toolset = AsyncMock(spec=BaseToolSet) + toolset.close = AsyncMock(side_effect=asyncio.TimeoutError()) + + # Should not raise exception + await runner._cleanup_toolsets({toolset}) + toolset.close.assert_called_once() + + @pytest.mark.asyncio + async def test_cleanup_toolsets_handles_exception(self, runner): + """Test cleanup handles toolset exception gracefully.""" + from trpc_agent_sdk.tools import BaseToolSet + + toolset = AsyncMock(spec=BaseToolSet) + toolset.close = AsyncMock(side_effect=Exception("Close failed")) + + # Should not raise exception + await runner._cleanup_toolsets({toolset}) + toolset.close.assert_called_once() + + @pytest.mark.asyncio + async def test_close_calls_all_services(self, runner, mock_session_service): + """Test close method calls close on all services.""" + memory_service = AsyncMock(spec=BaseMemoryService) + runner.memory_service = memory_service + + with patch.object(runner, '_collect_toolset', return_value=set()): + await runner.close() + + mock_session_service.close.assert_called_once() + memory_service.close.assert_called_once() + + @pytest.mark.asyncio + async def test_close_can_keep_external_services_open(self, mock_agent, mock_session_service): + """Test close skips externally managed session and memory services.""" + memory_service = AsyncMock(spec=BaseMemoryService) + runner = Runner( + app_name="test_app", + agent=mock_agent, + session_service=mock_session_service, + memory_service=memory_service, + close_session_service_on_close=False, + close_memory_service_on_close=False, + ) + + with patch.object(runner, '_collect_toolset', return_value=set()): + await runner.close() + + mock_session_service.close.assert_not_called() + memory_service.close.assert_not_called() + + +# Tests for session history handling +class TestSessionHistory: + """Tests for handling session history.""" + + @pytest.mark.asyncio + async def test_run_async_with_history_content(self, runner, mock_session_service, mock_agent, mock_session): + """Test handling of history content in messages.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Create message list with history + history_message = Content(parts=[Part(text="Historical context")]) + user_message = Content(parts=[Part(text="Current message")]) + message_list = [history_message, user_message] + + # Execute with save_history enabled + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=message_list, + run_config=RunConfig(save_history_enabled=True), + ): + pass + + # Verify history event was appended + calls = mock_session_service.append_event.call_args_list + # Should have history event, user event, and agent response + assert len(calls) >= 3 + + @pytest.mark.asyncio + async def test_run_async_triggers_summarization(self, runner, mock_session_service, mock_agent, mock_session): + """Test that summarization is triggered after agent execution.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + pass + + # Verify summarization was called + mock_session_service.create_session_summary.assert_called_once() + + @pytest.mark.asyncio + async def test_run_async_stores_in_memory_service(self, runner, mock_session_service, mock_agent, mock_session): + """Test that session is stored in memory service when enabled.""" + # Setup + memory_service = AsyncMock(spec=BaseMemoryService) + memory_service.enabled = True + runner.memory_service = memory_service + + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + yield Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Response")]), + partial=False, + ) + + mock_agent.run_async = mock_agent_run + + # Execute + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + pass + + # Verify memory service was called + memory_service.store_session.assert_called_once() + + +# Tests for invisible events +class TestInvisibleEvents: + """Tests for handling invisible events.""" + + @pytest.mark.asyncio + async def test_invisible_events_not_yielded(self, runner, mock_session_service, mock_agent, mock_session): + """Test that invisible events are not yielded to caller.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + # Yield invisible event + invisible_event = Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Invisible")]), + partial=False, + visible=False, + ) + yield invisible_event + + # Yield visible event + visible_event = Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Visible")]), + partial=False, + visible=True, + ) + yield visible_event + + mock_agent.run_async = mock_agent_run + + # Execute + events = [] + async for event in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + events.append(event) + + # Verify - should only receive visible event + assert len(events) == 1 + assert events[0].content.parts[0].text == "Visible" + + @pytest.mark.asyncio + async def test_invisible_event_with_transfer_raises_error(self, runner, mock_session_service, mock_agent, + mock_session): + """Test that invisible event with transfer request raises ValueError.""" + # Setup + mock_session_service.get_session.return_value = mock_session + + async def mock_agent_run(ctx): + event = Event( + invocation_id=ctx.invocation_id, + author="test_agent", + content=Content(parts=[Part(text="Invisible transfer")]), + partial=False, + visible=False, + ) + event.actions = EventActions(transfer_to_agent="other_agent") + yield event + + mock_agent.run_async = mock_agent_run + + # Execute and expect ValueError + with pytest.raises(ValueError, match="invisible is not allowed"): + async for _ in runner.run_async( + user_id="test_user", + session_id="test_session", + new_message=Content(parts=[Part(text="Hello")]), + ): + pass diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 000000000..858966866 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,13 @@ +# 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. + +"""Test the version module.""" + +from trpc_agent_sdk.version import __version__ + +def test_version(): + """Test the version module.""" + assert __version__ == '1.1.11' diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tools/goal_tools/test_goal_tools.py b/tests/tools/goal_tools/test_goal_tools.py new file mode 100644 index 000000000..a372c7c99 --- /dev/null +++ b/tests/tools/goal_tools/test_goal_tools.py @@ -0,0 +1,627 @@ +# 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. +"""Tests for the Goal capability (:mod:`trpc_agent_sdk.tools.goal_tools`).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from trpc_agent_sdk.agents._base_agent import BaseAgent +from trpc_agent_sdk.agents._constants import TRPC_AGENT_RUNNING_KEY +from trpc_agent_sdk.context import InvocationContext, create_agent_context +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.models import LlmRequest, LlmResponse +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools.goal_tools import ( + DEFAULT_STATE_KEY_PREFIX, + GoalCreateTool, + GoalGetTool, + GoalOptions, + GoalRecord, + GoalStatus, + GoalToolSet, + GoalUpdateTool, + decode_goal, + encode_goal, + get_goal_record, + render_goal, + setup_goal, + start_goal, + state_key, +) +from trpc_agent_sdk.tools.goal_tools._setup import ( + _REMINDER_PENDING_KEY, + _RETRY_COUNT_KEY, + _GoalCallbacks, +) +from trpc_agent_sdk.tools.goal_tools._prompt import _GUIDANCE_MARKER +from trpc_agent_sdk.types import Content, EventActions, Part + +AGENT_NAME = "goal_agent" + + +class _StubAgent(BaseAgent): + async def _run_async_impl(self, ctx): + yield + + +@pytest.fixture +def bundle(): + service = InMemorySessionService() + session = asyncio.run(service.create_session(app_name="test", user_id="u1", session_id="s1")) + agent = _StubAgent(name=AGENT_NAME) + ctx = InvocationContext( + session_service=service, + invocation_id="inv-1", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + return service, session, agent, ctx + + +async def _create(ctx, objective): + return await GoalCreateTool()._run_async_impl(tool_context=ctx, args={"objective": objective}) + + +async def _update(ctx, status): + return await GoalUpdateTool()._run_async_impl(tool_context=ctx, args={"status": status}) + + +async def _refresh_ctx(service, ctx, *, app_name="test", user_id="u1", session_id="s1"): + """Reload session state into an invocation context after host-side writes.""" + ctx.session = await service.get_session(app_name=app_name, user_id=user_id, session_id=session_id) + ctx.callback_state = None + + +async def _seed_goal(service, *, app_name, user_id, session_id, objective, branch=""): + """Application-layer goal write used by tests (appends a ``state_delta`` event).""" + import time + import uuid + + session = await service.get_session(app_name=app_name, user_id=user_id, session_id=session_id) + now = int(time.time()) + record = GoalRecord( + id=uuid.uuid4().hex, + objective=objective, + status=GoalStatus.ACTIVE, + createdAtUnix=now, + updatedAtUnix=now, + ) + key = state_key(DEFAULT_STATE_KEY_PREFIX, branch) + event = Event( + invocation_id="goal-" + uuid.uuid4().hex, + author="goal", + branch=branch or None, + actions=EventActions(state_delta={key: record.model_dump_json(by_alias=True)}), + ) + await service.append_event(session, event) + return record + + +def _final_text_response(text: str = "All done!") -> LlmResponse: + return LlmResponse(content=Content(role="model", parts=[Part.from_text(text=text)]), partial=False) + + +# --------------------------------------------------------------------------- # +# Tools # +# --------------------------------------------------------------------------- # +class TestGoalCreate: + @pytest.mark.asyncio + async def test_creates_active_goal(self, bundle): + _, _, agent, ctx = bundle + res = await _create(ctx, "Refactor the whole billing service") + assert res["goal"]["status"] == "active" + assert res["goal"]["objective"] == "Refactor the whole billing service" + assert state_key(DEFAULT_STATE_KEY_PREFIX, agent.name) in ctx.state._delta + + @pytest.mark.asyncio + async def test_rejects_empty_objective(self, bundle): + _, _, _, ctx = bundle + res = await GoalCreateTool()._run_async_impl(tool_context=ctx, args={"objective": " "}) + assert "error" in res + + @pytest.mark.asyncio + async def test_rejects_duplicate_active(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "first") + res = await _create(ctx, "second") + assert "error" in res + assert "active goal already exists" in res["error"] + + @pytest.mark.asyncio + async def test_can_recreate_after_terminal(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "first") + await _update(ctx, "complete") + res = await _create(ctx, "second") + assert "error" not in res + assert res["goal"]["objective"] == "second" + + +class TestGoalUpdate: + @pytest.mark.asyncio + async def test_complete_sets_terminal_time(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "obj") + res = await _update(ctx, "complete") + assert res["goal"]["status"] == "complete" + assert res["goal"]["terminalAtUnix"] is not None + + @pytest.mark.asyncio + async def test_blocked_is_terminal(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "obj") + res = await _update(ctx, "blocked") + assert res["goal"]["status"] == "blocked" + + @pytest.mark.asyncio + async def test_rejects_active_status(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "obj") + res = await GoalUpdateTool()._run_async_impl(tool_context=ctx, args={"status": "active"}) + assert "error" in res + + @pytest.mark.asyncio + async def test_rejects_when_no_goal(self, bundle): + _, _, _, ctx = bundle + res = await _update(ctx, "complete") + assert "error" in res + + @pytest.mark.asyncio + async def test_terminal_cannot_be_changed(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "obj") + await _update(ctx, "complete") + res = await _update(ctx, "blocked") + assert "error" in res + assert "terminal" in res["error"] + + +class TestStartGoal: + @pytest.mark.asyncio + async def test_writes_active_goal_to_existing_session(self): + service = InMemorySessionService() + await service.create_session(app_name="test", user_id="u1", session_id="s1") + + goal = await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="Ship the feature", + agent_name=AGENT_NAME, + ) + + assert goal.status == GoalStatus.ACTIVE + assert goal.objective == "Ship the feature" + assert goal.id + + session = await service.get_session(app_name="test", user_id="u1", session_id="s1") + stored = get_goal_record(session, branch=AGENT_NAME) + assert stored is not None + assert stored.id == goal.id + assert stored.objective == "Ship the feature" + assert session.state[state_key(DEFAULT_STATE_KEY_PREFIX, AGENT_NAME)] == encode_goal(goal) + + @pytest.mark.asyncio + async def test_creates_session_when_missing(self): + service = InMemorySessionService() + + goal = await start_goal( + service, + app_name="test", + user_id="u1", + session_id="brand-new-session", + objective="Bootstrap task", + agent_name=AGENT_NAME, + ) + + assert goal.status == GoalStatus.ACTIVE + session = await service.get_session(app_name="test", user_id="u1", session_id="brand-new-session") + assert session is not None + stored = get_goal_record(session, branch=AGENT_NAME) + assert stored is not None + assert stored.objective == "Bootstrap task" + + @pytest.mark.asyncio + async def test_rejects_empty_objective(self): + service = InMemorySessionService() + with pytest.raises(ValueError, match="non-empty"): + await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective=" ", + ) + + @pytest.mark.asyncio + async def test_strips_objective_whitespace(self): + service = InMemorySessionService() + + goal = await start_goal( + service, + app_name="test", + user_id="u1", + session_id="trim-session", + objective=" trimmed objective ", + ) + + assert goal.objective == "trimmed objective" + + @pytest.mark.asyncio + async def test_replaces_existing_goal(self): + service = InMemorySessionService() + await service.create_session(app_name="test", user_id="u1", session_id="s1") + + first = await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="first", + agent_name=AGENT_NAME, + ) + second = await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="second", + agent_name=AGENT_NAME, + ) + + assert second.id != first.id + assert second.objective == "second" + session = await service.get_session(app_name="test", user_id="u1", session_id="s1") + stored = get_goal_record(session, branch=AGENT_NAME) + assert stored is not None + assert stored.objective == "second" + + @pytest.mark.asyncio + async def test_honours_custom_state_key_prefix(self): + service = InMemorySessionService() + prefix = "custom_goal" + + await start_goal( + service, + app_name="test", + user_id="u1", + session_id="custom-prefix", + objective="scoped objective", + state_key_prefix=prefix, + agent_name="worker", + ) + + session = await service.get_session(app_name="test", user_id="u1", session_id="custom-prefix") + assert get_goal_record(session, branch="worker", prefix=prefix) is not None + assert get_goal_record(session, branch="worker", prefix=DEFAULT_STATE_KEY_PREFIX) is None + + @pytest.mark.asyncio + async def test_create_goal_rejects_after_start_goal(self, bundle): + service, _, agent, ctx = bundle + await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="host goal", + agent_name=agent.name, + ) + await _refresh_ctx(service, ctx) + + res = await _create(ctx, "model goal") + assert "error" in res + assert "active goal already exists" in res["error"] + + @pytest.mark.asyncio + async def test_get_goal_reads_host_injected_goal(self, bundle): + service, _, agent, ctx = bundle + await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="host objective", + agent_name=agent.name, + ) + await _refresh_ctx(service, ctx) + + res = await GoalGetTool()._run_async_impl(tool_context=ctx, args={}) + assert res["goal"]["objective"] == "host objective" + assert res["goal"]["status"] == "active" + + @pytest.mark.asyncio + async def test_enforcement_intercepts_premature_final(self, bundle): + service, _, agent, ctx = bundle + await start_goal( + service, + app_name="test", + user_id="u1", + session_id="s1", + objective="finish the job", + agent_name=agent.name, + ) + await _refresh_ctx(service, ctx) + + cb = _GoalCallbacks(GoalOptions()) + replaced = await cb.after_model(ctx, _final_text_response()) + + assert replaced is not None + assert replaced.partial is True + assert ctx.agent_context.metadata[TRPC_AGENT_RUNNING_KEY] is True + + +class TestGoalGet: + @pytest.mark.asyncio + async def test_no_goal(self, bundle): + _, _, _, ctx = bundle + res = await GoalGetTool()._run_async_impl(tool_context=ctx, args={}) + assert "goal" not in res + assert "No session goal" in res["message"] + + @pytest.mark.asyncio + async def test_returns_current(self, bundle): + _, _, _, ctx = bundle + await _create(ctx, "obj") + res = await GoalGetTool()._run_async_impl(tool_context=ctx, args={}) + assert res["goal"]["objective"] == "obj" + + +# --------------------------------------------------------------------------- # +# Enforcement callbacks # +# --------------------------------------------------------------------------- # +class TestEnforcement: + @pytest.mark.asyncio + async def test_before_model_injects_guidance_once(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + request = LlmRequest() + await cb.before_model(ctx, request) + await cb.before_model(ctx, request) + text = str(request.config.system_instruction) + assert text.count(_GUIDANCE_MARKER) == 1 + + @pytest.mark.asyncio + async def test_premature_final_triggers_rerun(self, bundle): + _, _, _, ctx = bundle + events = [] + cb = _GoalCallbacks(GoalOptions(on_retry=events.append)) + await _create(ctx, "obj") + + replaced = await cb.after_model(ctx, _final_text_response()) + + assert replaced is not None + assert replaced.partial is True + meta = ctx.agent_context.metadata + assert meta[TRPC_AGENT_RUNNING_KEY] is True + assert meta[_RETRY_COUNT_KEY] == 1 + assert meta[_REMINDER_PENDING_KEY] is True + assert len(events) == 1 and events[0].reason == "blocked" + + @pytest.mark.asyncio + async def test_nudge_appended_on_next_turn(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + await _create(ctx, "ship the feature") + await cb.after_model(ctx, _final_text_response()) + + request = LlmRequest() + await cb.before_model(ctx, request) + assert len(request.contents) == 1 + nudge_text = request.contents[0].parts[0].text + assert "ship the feature" in nudge_text + assert "attempt 1" in nudge_text + # reminder cleared after being consumed + assert ctx.agent_context.metadata[_REMINDER_PENDING_KEY] is False + + @pytest.mark.asyncio + async def test_fail_open_after_max_retries(self, bundle): + _, _, _, ctx = bundle + events = [] + cb = _GoalCallbacks(GoalOptions(max_retries=2, on_retry=events.append)) + await _create(ctx, "obj") + + assert await cb.after_model(ctx, _final_text_response()) is not None # attempt 1 + assert await cb.after_model(ctx, _final_text_response()) is not None # attempt 2 + # budget exhausted -> let the final response through (fail-open) + passthrough = await cb.after_model(ctx, _final_text_response()) + assert passthrough is None + assert ctx.agent_context.metadata[_RETRY_COUNT_KEY] == 0 + assert events[-1].reason == "exhausted" + + @pytest.mark.asyncio + async def test_partial_chunk_passes_through(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + await _create(ctx, "obj") + partial = LlmResponse(content=Content(role="model", parts=[Part.from_text(text="thinking")]), partial=True) + assert await cb.after_model(ctx, partial) is None + assert TRPC_AGENT_RUNNING_KEY not in ctx.agent_context.metadata + + @pytest.mark.asyncio + async def test_tool_call_response_passes_through(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + await _create(ctx, "obj") + tool_call = LlmResponse( + content=Content(role="model", parts=[Part.from_function_call(name="update_goal", args={"status": "complete"})]), + partial=False, + ) + assert await cb.after_model(ctx, tool_call) is None + + @pytest.mark.asyncio + async def test_no_interception_when_goal_terminal(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + await _create(ctx, "obj") + await _update(ctx, "complete") + # terminal goal: final response is a legitimate wrap-up + assert await cb.after_model(ctx, _final_text_response()) is None + + @pytest.mark.asyncio + async def test_no_interception_when_no_goal(self, bundle): + _, _, _, ctx = bundle + cb = _GoalCallbacks(GoalOptions()) + assert await cb.after_model(ctx, _final_text_response()) is None + + +# --------------------------------------------------------------------------- # +# setup_goal / helpers # +# --------------------------------------------------------------------------- # +class TestSetupGoal: + @pytest.mark.asyncio + async def test_appends_toolset_and_chains_callbacks(self): + from types import SimpleNamespace + + def prior_before(ctx, req): + return None + + # setup_goal only touches ``tools`` and the two model-callback fields. + agent = SimpleNamespace(tools=[], before_model_callback=prior_before, after_model_callback=None) + setup_goal(agent) + + # toolset appended + assert any(isinstance(t, GoalToolSet) for t in agent.tools) + # callbacks chained (prior preserved + new appended) + assert isinstance(agent.before_model_callback, list) + assert prior_before in agent.before_model_callback + assert len(agent.before_model_callback) == 2 + assert isinstance(agent.after_model_callback, list) + tools = await GoalToolSet().get_tools() + assert {t.name for t in tools} == {"get_goal", "create_goal", "update_goal"} + + +class TestPersistence: + @pytest.mark.asyncio + async def test_goal_survives_append_event_and_get_session(self, bundle): + service, session, agent, ctx = bundle + await _create(ctx, "obj") + event = Event( + invocation_id="inv-1", + author=agent.name, + content=Content(parts=[Part.from_text(text="tool result")]), + actions=EventActions(state_delta=dict(ctx.event_actions.state_delta)), + ) + await service.append_event(session, event) + stored = await service.get_session(app_name="test", user_id="u1", session_id="s1") + goal = get_goal_record(stored, branch=agent.name) + assert goal is not None and goal.status == GoalStatus.ACTIVE + + +class TestHelpers: + def test_state_key(self): + assert state_key(DEFAULT_STATE_KEY_PREFIX, "") == "goal" + assert state_key(DEFAULT_STATE_KEY_PREFIX, "agent") == "goal:agent" + + def test_decode_dirty_data_degrades_to_none(self): + assert decode_goal("not json") is None + assert decode_goal(None) is None + + def test_render_goal(self): + assert render_goal(None) == "(no goal)" + rec = GoalRecord(id="x", objective="do it", status=GoalStatus.ACTIVE, createdAtUnix=1, updatedAtUnix=1) + rendered = render_goal(rec) + assert "active" in rendered + assert "do it" in rendered + + +class TestGoalToolSet: + @pytest.mark.asyncio + async def test_returns_three_tools(self): + tools = await GoalToolSet().get_tools() + assert {t.name for t in tools} == {"get_goal", "create_goal", "update_goal"} + + +# --------------------------------------------------------------------------- # +# End-to-end: validates the same-invocation re-run lever (B2) via the Runner. # +# --------------------------------------------------------------------------- # +from typing import List # noqa: E402 + +from trpc_agent_sdk.models import LLMModel, ModelRegistry # noqa: E402 +from trpc_agent_sdk.runners import Runner # noqa: E402 + + +class _ScriptedModel(LLMModel): + """Premature-final on turn 1, ``update_goal(complete)`` on turn 2, final on turn 3.""" + + calls: int = 0 + saw_nudge: List[bool] = [] + + @classmethod + def supported_models(cls) -> List[str]: + return [r"scripted-.*"] + + def validate_request(self, request): + pass + + async def _generate_async_impl(self, request, stream=False, ctx=None): + type(self).calls += 1 + n = type(self).calls + nudged = any( + (c.role == "user") and c.parts and any("goal reminder" in (p.text or "") for p in c.parts) + for c in request.contents + ) + type(self).saw_nudge.append(nudged) + if n == 1: + yield LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="I refactored one function. Done!")]), + partial=False, + ) + elif n == 2: + part = Part.from_function_call(name="update_goal", args={"status": "complete"}) + part.function_call.id = "call-1" + yield LlmResponse(content=Content(role="model", parts=[part]), partial=False) + else: + yield LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="The whole task is finished.")]), + partial=False, + ) + + +class TestEndToEndRerun: + @pytest.mark.asyncio + async def test_premature_final_reruns_until_model_self_reports(self): + from trpc_agent_sdk.agents import LlmAgent + + original = ModelRegistry._registry.copy() + ModelRegistry.register(_ScriptedModel) + _ScriptedModel.calls = 0 + _ScriptedModel.saw_nudge = [] + try: + agent = LlmAgent(name="goal_e2e", model="scripted-1") + setup_goal(agent, GoalOptions(max_retries=3)) + + service = InMemorySessionService() + runner = Runner(app_name="goal_app", agent=agent, session_service=service) + await service.create_session(app_name="goal_app", user_id="u", session_id="sid") + await _seed_goal( + service, app_name="goal_app", user_id="u", session_id="sid", + objective="Refactor the entire service", branch=agent.name, + ) + + async for _ in runner.run_async( + user_id="u", + session_id="sid", + new_message=Content(role="user", parts=[Part.from_text(text="go")]), + ): + pass + await runner.close() + + # The loop re-ran within ONE invocation: model called 3 times. + assert _ScriptedModel.calls == 3 + # Turn 2 saw the nudge injected by before_model. + assert _ScriptedModel.saw_nudge[1] is True + # Goal ended up complete (model self-reported via update_goal). + stored = await service.get_session(app_name="goal_app", user_id="u", session_id="sid") + goal = get_goal_record(stored, branch=agent.name) + assert goal is not None and goal.status == GoalStatus.COMPLETE + finally: + ModelRegistry._registry = original diff --git a/tests/tools/mcp_tool/__init__.py b/tests/tools/mcp_tool/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tools/mcp_tool/test_mcp_session_manager.py b/tests/tools/mcp_tool/test_mcp_session_manager.py new file mode 100644 index 000000000..d9af6aa8d --- /dev/null +++ b/tests/tools/mcp_tool/test_mcp_session_manager.py @@ -0,0 +1,545 @@ +# 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. + +import asyncio +import hashlib +import json +from contextlib import AsyncExitStack +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest +from mcp import ClientSession, StdioServerParameters as McpStdioServerParameters + +from trpc_agent_sdk.tools.mcp_tool._mcp_session_manager import MCPSessionManager +from trpc_agent_sdk.tools.mcp_tool._types import ( + SseConnectionParams, + StdioConnectionParams, + StreamableHTTPConnectionParams, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _stdio_conn(timeout=5.0): + return StdioConnectionParams( + server_params=McpStdioServerParameters(command="echo", args=["hello"]), + timeout=timeout, + ) + + +def _sse_conn(url="http://example.com/sse", headers=None): + return SseConnectionParams(url=url, headers=headers or {}) + + +def _streamable_conn(url="http://example.com/stream", headers=None): + return StreamableHTTPConnectionParams(url=url, headers=headers or {}) + + +def _mock_session(closed=False): + session = MagicMock(spec=ClientSession) + read_stream = MagicMock() + read_stream._closed = closed + write_stream = MagicMock() + write_stream._closed = closed + session._read_stream = read_stream + session._write_stream = write_stream + return session + + +# --------------------------------------------------------------------------- +# Tests: __init__ +# --------------------------------------------------------------------------- + +class TestMCPSessionManagerInit: + def test_init_with_stdio(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + assert isinstance(mgr._connection_params, StdioConnectionParams) + assert mgr._sessions == {} + + def test_init_with_raw_stdio_server_params(self): + server_params = McpStdioServerParameters(command="echo") + mgr = MCPSessionManager(connection_params=server_params) + assert isinstance(mgr._connection_params, StdioConnectionParams) + + def test_init_session_group_params_default(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + assert mgr._session_group_params == {} + + def test_init_session_group_params_custom(self): + mgr = MCPSessionManager(connection_params=_stdio_conn(), session_group_params={"k": "v"}) + assert mgr._session_group_params == {"k": "v"} + + +# --------------------------------------------------------------------------- +# Tests: _is_cross_task_cancel_scope_error +# --------------------------------------------------------------------------- + +class TestIsCrossTaskCancelScopeError: + def test_matching_message(self): + err = RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + assert MCPSessionManager._is_cross_task_cancel_scope_error(err) is True + + def test_alternate_matching_message(self): + err = RuntimeError("cancel scope in different task") + assert MCPSessionManager._is_cross_task_cancel_scope_error(err) is True + + def test_non_matching_runtime_error(self): + err = RuntimeError("something else entirely") + assert MCPSessionManager._is_cross_task_cancel_scope_error(err) is False + + def test_non_runtime_error(self): + err = ValueError("cancel scope in different task") + assert MCPSessionManager._is_cross_task_cancel_scope_error(err) is False + + def test_base_exception(self): + err = KeyboardInterrupt() + assert MCPSessionManager._is_cross_task_cancel_scope_error(err) is False + + +# --------------------------------------------------------------------------- +# Tests: _generate_session_key +# --------------------------------------------------------------------------- + +class TestGenerateSessionKey: + def test_stdio_returns_constant(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + assert mgr._generate_session_key() == "stdio_session" + assert mgr._generate_session_key({"Authorization": "Bearer token"}) == "stdio_session" + + def test_sse_with_no_headers(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + assert mgr._generate_session_key(None) == "session_no_headers" + + def test_sse_with_headers(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + headers = {"Authorization": "Bearer abc123"} + key = mgr._generate_session_key(headers) + expected_hash = hashlib.md5(json.dumps(headers, sort_keys=True).encode()).hexdigest() + assert key == f"session_{expected_hash}" + + def test_same_headers_produce_same_key(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + h = {"X-Key": "val", "Authorization": "token"} + k1 = mgr._generate_session_key(h) + k2 = mgr._generate_session_key(h) + assert k1 == k2 + + def test_different_headers_produce_different_keys(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + k1 = mgr._generate_session_key({"X-Key": "a"}) + k2 = mgr._generate_session_key({"X-Key": "b"}) + assert k1 != k2 + + def test_streamable_with_headers(self): + mgr = MCPSessionManager(connection_params=_streamable_conn()) + headers = {"X-Api-Key": "secret"} + key = mgr._generate_session_key(headers) + assert key.startswith("session_") + assert key != "session_no_headers" + + +# --------------------------------------------------------------------------- +# Tests: _merge_headers +# --------------------------------------------------------------------------- + +class TestMergeHeaders: + def test_stdio_returns_none(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + assert mgr._merge_headers({"X-Extra": "val"}) is None + + def test_raw_stdio_server_params_returns_none(self): + mgr = MCPSessionManager(connection_params=McpStdioServerParameters(command="echo")) + assert mgr._merge_headers() is None + + def test_sse_no_base_no_additional(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + result = mgr._merge_headers() + assert result == {} + + def test_sse_with_base_headers(self): + conn = _sse_conn(headers={"Base": "val"}) + mgr = MCPSessionManager(connection_params=conn) + result = mgr._merge_headers() + assert result == {"Base": "val"} + + def test_sse_with_additional_headers(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + result = mgr._merge_headers({"Additional": "extra"}) + assert result == {"Additional": "extra"} + + def test_sse_merge_base_and_additional(self): + conn = _sse_conn(headers={"Base": "val"}) + mgr = MCPSessionManager(connection_params=conn) + result = mgr._merge_headers({"Additional": "extra"}) + assert result == {"Base": "val", "Additional": "extra"} + + def test_additional_overrides_base(self): + conn = _sse_conn(headers={"Key": "base"}) + mgr = MCPSessionManager(connection_params=conn) + result = mgr._merge_headers({"Key": "override"}) + assert result == {"Key": "override"} + + +# --------------------------------------------------------------------------- +# Tests: _is_session_disconnected +# --------------------------------------------------------------------------- + +class TestIsSessionDisconnected: + def test_connected_session(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + session = _mock_session(closed=False) + assert mgr._is_session_disconnected(session) is False + + def test_read_stream_closed(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + session = _mock_session(closed=False) + session._read_stream._closed = True + assert mgr._is_session_disconnected(session) is True + + def test_write_stream_closed(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + session = _mock_session(closed=False) + session._write_stream._closed = True + assert mgr._is_session_disconnected(session) is True + + def test_both_streams_closed(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + session = _mock_session(closed=True) + assert mgr._is_session_disconnected(session) is True + + +# --------------------------------------------------------------------------- +# Tests: _create_client +# --------------------------------------------------------------------------- + +class TestCreateClient: + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.stdio_client") + def test_stdio_client(self, mock_stdio): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + mgr._create_client() + mock_stdio.assert_called_once() + + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.sse_client") + def test_sse_client(self, mock_sse): + mgr = MCPSessionManager(connection_params=_sse_conn()) + mgr._create_client({"Auth": "token"}) + mock_sse.assert_called_once() + call_kwargs = mock_sse.call_args + assert call_kwargs.kwargs["headers"] == {"Auth": "token"} + + @patch.object(MCPSessionManager, "_create_streamable_http_client") + def test_streamable_client(self, mock_streamable): + mgr = MCPSessionManager(connection_params=_streamable_conn()) + mgr._create_client({"Auth": "token"}) + mock_streamable.assert_called_once_with({"Auth": "token"}) + + def test_unsupported_params_raises(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + mgr._connection_params = "unsupported" + with pytest.raises(ValueError, match="Unable to initialize connection"): + mgr._create_client() + + +# --------------------------------------------------------------------------- +# Tests: create_session +# --------------------------------------------------------------------------- + +class TestCreateSession: + @pytest.mark.asyncio + async def test_creates_new_session_stdio(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + mock_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=mock_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + + assert session is mock_session + assert len(mgr._sessions) == 1 + assert "stdio_session" in mgr._sessions + + @pytest.mark.asyncio + async def test_returns_existing_connected_session(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + existing_session = _mock_session(closed=False) + mock_exit_stack = MagicMock(spec=AsyncExitStack) + mgr._sessions["stdio_session"] = (existing_session, mock_exit_stack) + + session = await mgr.create_session() + assert session is existing_session + + @pytest.mark.asyncio + async def test_replaces_disconnected_session(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + disconnected_session = _mock_session(closed=True) + old_exit_stack = AsyncMock(spec=AsyncExitStack) + old_exit_stack.aclose = AsyncMock() + mgr._sessions["stdio_session"] = (disconnected_session, old_exit_stack) + + new_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=new_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + + assert session is new_session + old_exit_stack.aclose.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cleanup_disconnected_session_swallows_cross_task_error(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + disconnected_session = _mock_session(closed=True) + old_exit_stack = AsyncMock(spec=AsyncExitStack) + old_exit_stack.aclose = AsyncMock( + side_effect=RuntimeError("Attempted to exit cancel scope in a different task than it was entered in") + ) + mgr._sessions["stdio_session"] = (disconnected_session, old_exit_stack) + + new_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=new_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + assert session is new_session + + @pytest.mark.asyncio + async def test_cleanup_disconnected_session_logs_other_errors(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + disconnected_session = _mock_session(closed=True) + old_exit_stack = AsyncMock(spec=AsyncExitStack) + old_exit_stack.aclose = AsyncMock(side_effect=RuntimeError("something else")) + mgr._sessions["stdio_session"] = (disconnected_session, old_exit_stack) + + new_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=new_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + assert session is new_session + + @pytest.mark.asyncio + async def test_create_session_failure_raises_runtime_error(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + with patch.object(mgr, "_create_client", side_effect=Exception("client creation failed")): + with pytest.raises(RuntimeError, match="Error creating session"): + await mgr.create_session() + + @pytest.mark.asyncio + async def test_create_session_with_timedelta_timeout(self): + conn = _stdio_conn() + conn.timeout = timedelta(seconds=10) + mgr = MCPSessionManager(connection_params=conn) + + mock_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=mock_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + assert session is mock_session + call_kwargs = mock_cs_cls.call_args + assert call_kwargs.kwargs["read_timeout_seconds"] == timedelta(seconds=10) + + @pytest.mark.asyncio + async def test_create_session_with_float_timeout(self): + conn = _stdio_conn(timeout=15.0) + mgr = MCPSessionManager(connection_params=conn) + + mock_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=mock_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + assert session is mock_session + call_kwargs = mock_cs_cls.call_args + assert call_kwargs.kwargs["read_timeout_seconds"] == timedelta(seconds=15.0) + + @pytest.mark.asyncio + async def test_create_session_non_stdio(self): + mgr = MCPSessionManager(connection_params=_sse_conn()) + + mock_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client), \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=mock_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session() + assert session is mock_session + + @pytest.mark.asyncio + async def test_create_session_with_headers(self): + conn = _sse_conn(headers={"Base": "base_val"}) + mgr = MCPSessionManager(connection_params=conn) + + mock_session = _mock_session(closed=False) + mock_client = AsyncMock() + mock_transports = (MagicMock(), MagicMock()) + + with patch.object(mgr, "_create_client", return_value=mock_client) as mock_create, \ + patch("trpc_agent_sdk.tools.mcp_tool._mcp_session_manager.ClientSession") as mock_cs_cls: + mock_client.__aenter__ = AsyncMock(return_value=mock_transports) + mock_client.__aexit__ = AsyncMock(return_value=False) + + mock_cs_instance = AsyncMock() + mock_cs_instance.initialize = AsyncMock() + mock_cs_cls.return_value = mock_cs_instance + mock_cs_instance.__aenter__ = AsyncMock(return_value=mock_session) + mock_cs_instance.__aexit__ = AsyncMock(return_value=False) + + session = await mgr.create_session(headers={"Extra": "extra_val"}) + + assert session is mock_session + call_args = mock_create.call_args + merged = call_args[1].get("merged_headers") or call_args[0][0] + assert "Base" in merged + assert "Extra" in merged + + +# --------------------------------------------------------------------------- +# Tests: close +# --------------------------------------------------------------------------- + +class TestClose: + @pytest.mark.asyncio + async def test_close_all_sessions(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + exit_stack1 = AsyncMock(spec=AsyncExitStack) + exit_stack1.aclose = AsyncMock() + exit_stack2 = AsyncMock(spec=AsyncExitStack) + exit_stack2.aclose = AsyncMock() + + mgr._sessions = { + "session_1": (_mock_session(), exit_stack1), + "session_2": (_mock_session(), exit_stack2), + } + + await mgr.close() + + exit_stack1.aclose.assert_awaited_once() + exit_stack2.aclose.assert_awaited_once() + assert len(mgr._sessions) == 0 + + @pytest.mark.asyncio + async def test_close_empty_sessions(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + await mgr.close() + assert len(mgr._sessions) == 0 + + @pytest.mark.asyncio + async def test_close_swallows_cleanup_errors(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + exit_stack = AsyncMock(spec=AsyncExitStack) + exit_stack.aclose = AsyncMock(side_effect=RuntimeError("cleanup failed")) + + mgr._sessions = { + "session_1": (_mock_session(), exit_stack), + } + + await mgr.close() + assert len(mgr._sessions) == 0 + + @pytest.mark.asyncio + async def test_close_handles_multiple_errors(self): + mgr = MCPSessionManager(connection_params=_stdio_conn()) + + exit_stack1 = AsyncMock(spec=AsyncExitStack) + exit_stack1.aclose = AsyncMock(side_effect=RuntimeError("err1")) + exit_stack2 = AsyncMock(spec=AsyncExitStack) + exit_stack2.aclose = AsyncMock(side_effect=RuntimeError("err2")) + + mgr._sessions = { + "s1": (_mock_session(), exit_stack1), + "s2": (_mock_session(), exit_stack2), + } + + await mgr.close() + assert len(mgr._sessions) == 0 diff --git a/tests/tools/mcp_tool/test_mcp_tool.py b/tests/tools/mcp_tool/test_mcp_tool.py new file mode 100644 index 000000000..4260bbc54 --- /dev/null +++ b/tests/tools/mcp_tool/test_mcp_tool.py @@ -0,0 +1,422 @@ +# 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. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp.types import ( + CallToolResult, + EmbeddedResource, + ImageContent, + TextContent, + TextResourceContents, + BlobResourceContents, + Tool as McpBaseTool, +) + +from trpc_agent_sdk.tools.mcp_tool._mcp_tool import MCPTool +from trpc_agent_sdk.tools.mcp_tool._mcp_session_manager import MCPSessionManager +from trpc_agent_sdk.types import FunctionDeclaration, Schema + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mcp_tool(name="test_tool", description="A test tool", input_schema=None): + return McpBaseTool( + name=name, + description=description, + inputSchema=input_schema or {"type": "object", "properties": {}}, + ) + + +def _make_session_manager(): + mgr = MagicMock(spec=MCPSessionManager) + mgr.create_session = AsyncMock() + return mgr + + +def _make_mcp_tool_instance(name="test_tool", description="A test tool", input_schema=None, **kwargs): + mcp_tool = _make_mcp_tool(name=name, description=description, input_schema=input_schema) + mgr = _make_session_manager() + return MCPTool(mcp_tool=mcp_tool, mcp_session_manager=mgr, **kwargs), mgr + + +# --------------------------------------------------------------------------- +# Tests: __init__ +# --------------------------------------------------------------------------- + +class TestMCPToolInit: + def test_basic_init(self): + tool, mgr = _make_mcp_tool_instance() + assert tool.name == "test_tool" + assert tool.description == "A test tool" + + def test_none_mcp_tool_raises(self): + mgr = _make_session_manager() + with pytest.raises(ValueError, match="cannot be None"): + MCPTool(mcp_tool=None, mcp_session_manager=mgr) + + def test_none_session_manager_raises(self): + mcp_tool = _make_mcp_tool() + with pytest.raises(ValueError, match="cannot be None"): + MCPTool(mcp_tool=mcp_tool, mcp_session_manager=None) + + def test_empty_description_uses_empty_string(self): + mcp_tool = McpBaseTool( + name="t", + description=None, + inputSchema={"type": "object"}, + ) + mgr = _make_session_manager() + tool = MCPTool(mcp_tool=mcp_tool, mcp_session_manager=mgr) + assert tool.description == "" + + def test_filters_passed(self): + mock_filter = MagicMock() + mock_filter.name = "my_filter" + tool, _ = _make_mcp_tool_instance(filters=[mock_filter]) + assert len(tool._filters) == 1 + + +# --------------------------------------------------------------------------- +# Tests: _clean_schema +# --------------------------------------------------------------------------- + +class TestCleanSchema: + def _tool(self): + tool, _ = _make_mcp_tool_instance() + return tool + + def test_empty_schema(self): + tool = self._tool() + assert tool._clean_schema({}) == {} + + def test_none_schema(self): + tool = self._tool() + assert tool._clean_schema(None) is None + + def test_falsy_schema(self): + tool = self._tool() + assert tool._clean_schema(0) == 0 + + def test_converts_dollar_defs_to_defs(self): + tool = self._tool() + schema = { + "$defs": { + "Foo": {"type": "string"}, + }, + "type": "object", + } + result = tool._clean_schema(schema) + assert "defs" in result + assert "$defs" not in result + assert result["defs"]["Foo"] == {"type": "string"} + + def test_converts_dollar_ref_to_ref(self): + tool = self._tool() + schema = {"$ref": "#/$defs/Foo"} + result = tool._clean_schema(schema) + assert result["ref"] == "#/defs/Foo" + assert "$ref" not in result + + def test_converts_anyOf_to_any_of(self): + tool = self._tool() + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + ] + } + result = tool._clean_schema(schema) + assert "any_of" in result + assert "anyOf" not in result + assert len(result["any_of"]) == 2 + + def test_removes_unsupported_fields(self): + tool = self._tool() + schema = { + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "some-id", + "definitions": {"Foo": {"type": "string"}}, + "const": "fixed_value", + } + result = tool._clean_schema(schema) + assert "type" in result + assert "$schema" not in result + assert "$id" not in result + assert "definitions" not in result + assert "const" not in result + + def test_normalizes_type_list_to_first_element(self): + tool = self._tool() + schema = {"type": ["string", "null"]} + result = tool._clean_schema(schema) + assert result["type"] == "string" + + def test_normalizes_empty_type_list_unchanged(self): + tool = self._tool() + schema = {"type": []} + result = tool._clean_schema(schema) + assert result["type"] == [] + + def test_recursively_cleans_properties(self): + tool = self._tool() + schema = { + "type": "object", + "properties": { + "name": {"type": ["string", "null"], "$ref": "#/$defs/X"}, + }, + } + result = tool._clean_schema(schema) + prop = result["properties"]["name"] + assert prop["type"] == "string" + assert prop.get("ref") == "#/defs/X" + + def test_recursively_cleans_items(self): + tool = self._tool() + schema = { + "type": "array", + "items": {"$ref": "#/$defs/Item"}, + } + result = tool._clean_schema(schema) + assert result["items"]["ref"] == "#/defs/Item" + + def test_recursively_cleans_any_of(self): + tool = self._tool() + schema = { + "any_of": [ + {"$ref": "#/$defs/A"}, + {"type": "string"}, + ] + } + result = tool._clean_schema(schema) + assert result["any_of"][0]["ref"] == "#/defs/A" + + def test_recursively_cleans_defs_entries(self): + tool = self._tool() + schema = { + "$defs": { + "Alert": { + "type": "object", + "properties": { + "msg": {"type": ["string", "null"]}, + }, + }, + }, + "type": "object", + } + result = tool._clean_schema(schema) + alert_props = result["defs"]["Alert"]["properties"] + assert alert_props["msg"]["type"] == "string" + + def test_does_not_modify_original_schema(self): + tool = self._tool() + original = { + "$defs": {"A": {"type": "string"}}, + "$ref": "#/$defs/A", + "type": "object", + } + original_copy = original.copy() + tool._clean_schema(original) + assert "$defs" in original + assert "$ref" in original + + def test_anyOf_with_non_dict_items(self): + tool = self._tool() + schema = { + "anyOf": [ + {"type": "string"}, + "raw_value", + ] + } + result = tool._clean_schema(schema) + assert result["any_of"][1] == "raw_value" + + def test_complex_nested_schema(self): + tool = self._tool() + schema = { + "type": "object", + "$defs": { + "DailyForecast": { + "type": "object", + "properties": { + "alerts": { + "type": "array", + "items": {"$ref": "#/$defs/Alert"}, + }, + }, + }, + "Alert": { + "type": "object", + "properties": { + "severity": {"type": "string"}, + }, + }, + }, + "properties": { + "forecast": {"$ref": "#/$defs/DailyForecast"}, + }, + } + result = tool._clean_schema(schema) + assert "defs" in result + assert result["properties"]["forecast"]["ref"] == "#/defs/DailyForecast" + items_ref = result["defs"]["DailyForecast"]["properties"]["alerts"]["items"] + assert items_ref["ref"] == "#/defs/Alert" + + +# --------------------------------------------------------------------------- +# Tests: _get_declaration +# --------------------------------------------------------------------------- + +class TestGetDeclaration: + def test_returns_function_declaration(self): + tool, _ = _make_mcp_tool_instance( + input_schema={"type": "object", "properties": {"x": {"type": "string"}}}, + ) + decl = tool._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "test_tool" + assert decl.description == "A test tool" + assert decl.parameters is not None + + def test_empty_input_schema(self): + mcp_tool = McpBaseTool(name="t", description="d", inputSchema={}) + mgr = _make_session_manager() + tool = MCPTool(mcp_tool=mcp_tool, mcp_session_manager=mgr) + # Patch _mcp_tool.inputSchema to simulate None at declaration time + tool._mcp_tool.inputSchema = None + decl = tool._get_declaration() + assert decl.parameters is None + + def test_with_output_schema(self): + mcp_tool = McpBaseTool( + name="t", + description="d", + inputSchema={"type": "object"}, + outputSchema={"type": "string"}, + ) + mgr = _make_session_manager() + tool = MCPTool(mcp_tool=mcp_tool, mcp_session_manager=mgr) + decl = tool._get_declaration() + assert decl.response is not None + + +# --------------------------------------------------------------------------- +# Tests: _parse_mcp_call_tool_result_to_str +# --------------------------------------------------------------------------- + +class TestParseMcpCallToolResult: + def _tool(self): + tool, _ = _make_mcp_tool_instance() + return tool + + def test_error_result(self): + tool = self._tool() + result = CallToolResult( + isError=True, + content=[TextContent(type="text", text="something failed")], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == "Error: something failed" + + def test_text_content(self): + tool = self._tool() + result = CallToolResult( + isError=False, + content=[TextContent(type="text", text="hello world")], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == "hello world" + + def test_image_content(self): + tool = self._tool() + result = CallToolResult( + isError=False, + content=[ImageContent(type="image", data="base64data", mimeType="image/png")], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == "base64data" + + def test_resource_content_with_text(self): + tool = self._tool() + resource = TextResourceContents(uri="file:///test.txt", text="resource text", mimeType="text/plain") + result = CallToolResult( + isError=False, + content=[EmbeddedResource(type="resource", resource=resource)], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == "resource text" + + def test_resource_content_with_blob(self): + tool = self._tool() + resource = BlobResourceContents(uri="file:///test.bin", blob="blob_data", mimeType="application/octet-stream") + result = CallToolResult( + isError=False, + content=[EmbeddedResource(type="resource", resource=resource)], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == "blob_data" + + def test_multiple_contents_returns_list(self): + tool = self._tool() + result = CallToolResult( + isError=False, + content=[ + TextContent(type="text", text="first"), + TextContent(type="text", text="second"), + ], + ) + assert tool._parse_mcp_call_tool_result_to_str(result) == ["first", "second"] + + def test_fallback_returns_raw_content(self): + """When no content type matches, stringified raw content is returned.""" + tool = self._tool() + result = MagicMock() + result.isError = False + mock_content = MagicMock() + mock_content.type = "unknown" + result.content = [mock_content] + ret = tool._parse_mcp_call_tool_result_to_str(result) + assert ret == str(result.content) + + +# --------------------------------------------------------------------------- +# Tests: _run_async_impl +# --------------------------------------------------------------------------- + +class TestRunAsyncImpl: + @pytest.mark.asyncio + async def test_calls_session_and_returns_result(self): + tool, mgr = _make_mcp_tool_instance() + mock_session = AsyncMock() + mgr.create_session.return_value = mock_session + mock_session.call_tool.return_value = CallToolResult( + isError=False, + content=[TextContent(type="text", text="ok")], + ) + + mock_ctx = MagicMock() + result = await tool._run_async_impl(args={"key": "value"}, tool_context=mock_ctx) + assert result == "ok" + mock_session.call_tool.assert_awaited_once_with("test_tool", arguments={"key": "value"}) + + @pytest.mark.asyncio + async def test_raises_on_session_error(self): + tool, mgr = _make_mcp_tool_instance() + mgr.create_session.side_effect = RuntimeError("connection failed") + + mock_ctx = MagicMock() + with pytest.raises(RuntimeError, match="connection failed"): + await tool._run_async_impl(args={}, tool_context=mock_ctx) + + @pytest.mark.asyncio + async def test_raises_on_call_tool_error(self): + tool, mgr = _make_mcp_tool_instance() + mock_session = AsyncMock() + mgr.create_session.return_value = mock_session + mock_session.call_tool.side_effect = Exception("tool execution failed") + + mock_ctx = MagicMock() + with pytest.raises(Exception, match="tool execution failed"): + await tool._run_async_impl(args={}, tool_context=mock_ctx) diff --git a/tests/tools/mcp_tool/test_mcp_toolset.py b/tests/tools/mcp_tool/test_mcp_toolset.py new file mode 100644 index 000000000..a82570175 --- /dev/null +++ b/tests/tools/mcp_tool/test_mcp_toolset.py @@ -0,0 +1,523 @@ +# 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. + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp import types as mcp_types +from mcp import StdioServerParameters as McpStdioServerParameters +from mcp.types import ListToolsResult, Tool as McpBaseTool + +from trpc_agent_sdk.tools.mcp_tool._mcp_toolset import MCPToolset +from trpc_agent_sdk.tools.mcp_tool._mcp_tool import MCPTool +from trpc_agent_sdk.tools.mcp_tool._mcp_session_manager import MCPSessionManager +from trpc_agent_sdk.tools.mcp_tool._types import StdioConnectionParams + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _stdio_conn(): + return StdioConnectionParams( + server_params=McpStdioServerParameters(command="echo", args=["hello"]), + ) + + +def _server_capabilities(list_changed: bool | None = None): + tools_capability = None + if list_changed is not None: + tools_capability = mcp_types.ToolsCapability(listChanged=list_changed) + return mcp_types.ServerCapabilities(tools=tools_capability) + + +# --------------------------------------------------------------------------- +# Tests: __init__ +# --------------------------------------------------------------------------- + +class TestMCPToolsetInit: + def test_default_init(self): + ts = MCPToolset(connection_params=_stdio_conn()) + assert ts._connection_params is not None + assert ts._mcp_session_manager is None + assert ts._filters is None + assert ts._filters_name is None + + def test_with_tool_filter_list(self): + ts = MCPToolset(connection_params=_stdio_conn(), tool_filter=["tool_a"]) + assert ts._tool_filter == ["tool_a"] + + def test_with_tool_filter_predicate(self): + pred = lambda tool, ctx=None: True + ts = MCPToolset(connection_params=_stdio_conn(), tool_filter=pred) + assert ts._tool_filter is pred + + def test_with_filters(self): + mock_filter = MagicMock() + ts = MCPToolset( + connection_params=_stdio_conn(), + filters_name=["f1"], + filters=[mock_filter], + ) + assert ts._filters_name == ["f1"] + assert ts._filters == [mock_filter] + + def test_custom_mcp_tool_cls(self): + custom_cls = MagicMock() + ts = MCPToolset(connection_params=_stdio_conn(), mcp_tool_cls=custom_cls) + assert ts._mcp_tool_cls is custom_cls + + def test_session_group_params_default_empty(self): + ts = MCPToolset(connection_params=_stdio_conn()) + assert ts._session_group_params == {} + + def test_session_group_params_custom(self): + ts = MCPToolset(connection_params=_stdio_conn(), session_group_params={"key": "val"}) + assert ts._session_group_params == {"key": "val"} + + def test_tools_cache_enabled_by_default(self): + ts = MCPToolset(connection_params=_stdio_conn()) + assert ts._cache_tools is True + assert ts._tools_cache_ttl == 60.0 + + def test_rejects_negative_tools_cache_ttl(self): + with pytest.raises(ValueError, match="tools_cache_ttl must be non-negative"): + MCPToolset(connection_params=_stdio_conn(), tools_cache_ttl=-1) + + +# --------------------------------------------------------------------------- +# Tests: _checker_required_params +# --------------------------------------------------------------------------- + +class TestCheckerRequiredParams: + def test_raises_when_connection_params_none(self): + ts = MCPToolset() + ts._connection_params = None + with pytest.raises(ValueError, match="_connection_params is None"): + ts._checker_required_params() + + def test_raises_when_session_manager_none(self): + ts = MCPToolset(connection_params=_stdio_conn()) + ts._mcp_session_manager = None + with pytest.raises(ValueError, match="_mcp_session_manager is None"): + ts._checker_required_params() + + +# --------------------------------------------------------------------------- +# Tests: initialize +# --------------------------------------------------------------------------- + +class TestInitialize: + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.MCPSessionManager") + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.convert_conn_params") + def test_initialize_creates_session_manager(self, mock_convert, mock_mgr_cls): + conn = _stdio_conn() + mock_convert.return_value = conn + mock_mgr_cls.return_value = MagicMock(spec=MCPSessionManager) + + ts = MCPToolset(connection_params=conn) + ts.initialize() + + mock_convert.assert_called_once() + mock_mgr_cls.assert_called_once() + assert ts._mcp_session_manager is not None + + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.MCPSessionManager") + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.convert_conn_params") + def test_initialize_idempotent(self, mock_convert, mock_mgr_cls): + conn = _stdio_conn() + mock_convert.return_value = conn + mgr_instance = MagicMock(spec=MCPSessionManager) + mock_mgr_cls.return_value = mgr_instance + + ts = MCPToolset(connection_params=conn) + ts.initialize() + ts.initialize() + + mock_mgr_cls.assert_called_once() + + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.MCPSessionManager") + @patch("trpc_agent_sdk.tools.mcp_tool._mcp_toolset.convert_conn_params") + def test_initialize_with_stdio_server_params(self, mock_convert, mock_mgr_cls): + """StdioServerParameters should be auto-converted via convert_conn_params.""" + server_params = McpStdioServerParameters(command="npx") + conn = StdioConnectionParams(server_params=server_params) + mock_convert.return_value = conn + mock_mgr_cls.return_value = MagicMock(spec=MCPSessionManager) + + ts = MCPToolset(connection_params=server_params) + ts.initialize() + + mock_convert.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests: get_tools +# --------------------------------------------------------------------------- + +class TestGetTools: + @pytest.mark.asyncio + async def test_get_tools_returns_all_tools(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + McpBaseTool(name="tool_b", description="desc_b", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize") as mock_init: + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + assert len(tools) == 2 + assert all(isinstance(t, MCPTool) for t in tools) + names = {t.name for t in tools} + assert names == {"tool_a", "tool_b"} + + @pytest.mark.asyncio + async def test_get_tools_with_list_filter(self): + ts = MCPToolset( + connection_params=_stdio_conn(), + tool_filter=["tool_a"], + is_include_all_tools=False, + ) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + McpBaseTool(name="tool_b", description="desc_b", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + assert len(tools) == 1 + assert tools[0].name == "tool_a" + + @pytest.mark.asyncio + async def test_get_tools_with_predicate_filter(self): + pred = lambda tool, ctx=None: tool.name.startswith("allow") + ts = MCPToolset( + connection_params=_stdio_conn(), + tool_filter=pred, + is_include_all_tools=False, + ) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="allow_tool", description="ok", inputSchema={"type": "object"}), + McpBaseTool(name="deny_tool", description="no", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + assert len(tools) == 1 + assert tools[0].name == "allow_tool" + + @pytest.mark.asyncio + async def test_get_tools_empty_server(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=[])) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + assert tools == [] + + @pytest.mark.asyncio + async def test_get_tools_passes_filters_to_mcp_tool(self): + mock_filter = MagicMock() + ts = MCPToolset( + connection_params=_stdio_conn(), + filters=[mock_filter], + ) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + assert len(tools) == 1 + assert len(tools[0]._filters) == 1 + + @pytest.mark.asyncio + async def test_get_tools_calls_initialize(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=[])) + + with patch.object(ts, "initialize") as mock_init: + ts._mcp_session_manager = mock_mgr + await ts.get_tools() + mock_init.assert_called_once() + + @pytest.mark.asyncio + async def test_get_tools_with_custom_mcp_tool_cls(self): + custom_cls = MagicMock() + custom_tool_instance = MagicMock() + custom_tool_instance.name = "custom" + custom_cls.return_value = custom_tool_instance + + ts = MCPToolset(connection_params=_stdio_conn(), mcp_tool_cls=custom_cls) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="tool_a", description="desc", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + tools = await ts.get_tools() + + custom_cls.assert_called_once() + assert len(tools) == 1 + + @pytest.mark.asyncio + async def test_get_tools_reuses_cached_list_tools_response(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mcp_tools = [ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ] + mock_session.list_tools = AsyncMock(return_value=ListToolsResult(tools=mcp_tools)) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + first = await ts.get_tools() + second = await ts.get_tools() + + assert [tool.name for tool in first] == ["tool_a"] + assert [tool.name for tool in second] == ["tool_a"] + mock_session.list_tools.assert_awaited_once() + + @pytest.mark.asyncio + async def test_get_tools_can_disable_tools_cache(self): + ts = MCPToolset(connection_params=_stdio_conn(), cache_tools=False) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mock_session.list_tools = AsyncMock( + return_value=ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ] + )) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + await ts.get_tools() + await ts.get_tools() + + assert mock_session.list_tools.await_count == 2 + + @pytest.mark.asyncio + async def test_clear_tools_cache_forces_refresh(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mock_session.list_tools = AsyncMock( + side_effect=[ + ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ]), + ListToolsResult( + tools=[ + McpBaseTool(name="tool_b", description="desc_b", inputSchema={"type": "object"}), + ]), + ]) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + first = await ts.get_tools() + ts.clear_tools_cache() + second = await ts.get_tools() + + assert [tool.name for tool in first] == ["tool_a"] + assert [tool.name for tool in second] == ["tool_b"] + assert mock_session.list_tools.await_count == 2 + + @pytest.mark.asyncio + async def test_tools_cache_ttl_expires(self): + ts = MCPToolset(connection_params=_stdio_conn(), tools_cache_ttl=1) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mock_session.list_tools = AsyncMock( + side_effect=[ + ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ]), + ListToolsResult( + tools=[ + McpBaseTool(name="tool_b", description="desc_b", inputSchema={"type": "object"}), + ]), + ]) + + with patch.object(ts, "initialize"), patch( + "trpc_agent_sdk.tools.mcp_tool._mcp_toolset.time.monotonic", + side_effect=[100.0, 100.5, 101.1, 101.1, 101.1], + ): + ts._mcp_session_manager = mock_mgr + first = await ts.get_tools() + cached = await ts.get_tools() + refreshed = await ts.get_tools() + + assert [tool.name for tool in first] == ["tool_a"] + assert [tool.name for tool in cached] == ["tool_a"] + assert [tool.name for tool in refreshed] == ["tool_b"] + assert mock_session.list_tools.await_count == 2 + + @pytest.mark.asyncio + async def test_list_changed_capability_uses_notification_driven_cache(self): + ts = MCPToolset(connection_params=_stdio_conn(), tools_cache_ttl=1) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_session.get_server_capabilities = MagicMock(return_value=_server_capabilities(list_changed=True)) + mock_mgr.create_session = AsyncMock(return_value=mock_session) + + mock_session.list_tools = AsyncMock( + return_value=ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ] + )) + + with patch.object(ts, "initialize"), patch( + "trpc_agent_sdk.tools.mcp_tool._mcp_toolset.time.monotonic", + return_value=100.0, + ): + ts._mcp_session_manager = mock_mgr + first = await ts.get_tools() + second = await ts.get_tools() + + assert [tool.name for tool in first] == ["tool_a"] + assert [tool.name for tool in second] == ["tool_a"] + mock_session.list_tools.assert_awaited_once() + + @pytest.mark.asyncio + async def test_tool_list_changed_notification_clears_cache_and_chains_handler(self): + user_message_handler = AsyncMock() + ts = MCPToolset( + connection_params=_stdio_conn(), + session_group_params={"message_handler": user_message_handler}, + ) + ts._tools_cache = ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ]) + ts._tools_cache_updated_at = 100.0 + + params = ts._build_session_group_params() + notification = mcp_types.ServerNotification(mcp_types.ToolListChangedNotification()) + await params["message_handler"](notification) + + assert ts._tools_cache is None + assert ts._tools_cache_updated_at is None + user_message_handler.assert_awaited_once_with(notification) + + @pytest.mark.asyncio + async def test_concurrent_get_tools_shares_cache_fill(self): + ts = MCPToolset(connection_params=_stdio_conn()) + + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_session = AsyncMock() + mock_mgr.create_session = AsyncMock(return_value=mock_session) + mock_session.list_tools = AsyncMock( + return_value=ListToolsResult( + tools=[ + McpBaseTool(name="tool_a", description="desc_a", inputSchema={"type": "object"}), + ] + )) + + with patch.object(ts, "initialize"): + ts._mcp_session_manager = mock_mgr + first, second = await asyncio.gather(ts.get_tools(), ts.get_tools()) + + assert [tool.name for tool in first] == ["tool_a"] + assert [tool.name for tool in second] == ["tool_a"] + mock_session.list_tools.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# Tests: close +# --------------------------------------------------------------------------- + +class TestClose: + @pytest.mark.asyncio + async def test_close_delegates_to_session_manager(self): + ts = MCPToolset(connection_params=_stdio_conn()) + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_mgr.close = AsyncMock() + ts._mcp_session_manager = mock_mgr + + await ts.close() + mock_mgr.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_close_when_no_session_manager(self): + ts = MCPToolset(connection_params=_stdio_conn()) + ts._mcp_session_manager = None + await ts.close() + + @pytest.mark.asyncio + async def test_close_swallows_exception(self): + ts = MCPToolset(connection_params=_stdio_conn()) + mock_mgr = MagicMock(spec=MCPSessionManager) + mock_mgr.close = AsyncMock(side_effect=RuntimeError("cleanup error")) + ts._mcp_session_manager = mock_mgr + + await ts.close() diff --git a/tests/tools/mcp_tool/test_types.py b/tests/tools/mcp_tool/test_types.py new file mode 100644 index 000000000..606e5d209 --- /dev/null +++ b/tests/tools/mcp_tool/test_types.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 Apache-2.0. + +import pytest +from mcp import StdioServerParameters as McpStdioServerParameters +from mcp.client.session_group import SseServerParameters as McpSseServerParameters +from mcp.client.session_group import StreamableHttpParameters as McpStreamableHttpParameters + +from trpc_agent_sdk.tools.mcp_tool._types import ( + DEFAULT_TIMEOUT, + McpConnectionParamsType, + SseConnectionParams, + StdioConnectionParams, + StreamableHTTPConnectionParams, +) + + +class TestDefaultTimeout: + def test_default_timeout_value(self): + assert DEFAULT_TIMEOUT == 5.0 + + +class TestStdioConnectionParams: + def test_create_with_server_params(self): + server_params = McpStdioServerParameters(command="npx", args=["-y", "server"]) + conn = StdioConnectionParams(server_params=server_params) + + assert conn.server_params == server_params + assert conn.timeout == DEFAULT_TIMEOUT + + def test_create_with_custom_timeout(self): + server_params = McpStdioServerParameters(command="python3", args=["server.py"]) + conn = StdioConnectionParams(server_params=server_params, timeout=10.0) + + assert conn.timeout == 10.0 + + def test_missing_server_params_raises(self): + with pytest.raises(Exception): + StdioConnectionParams() + + +class TestTypeAliases: + def test_streamable_http_is_mcp_alias(self): + assert StreamableHTTPConnectionParams is McpStreamableHttpParameters + + def test_sse_is_mcp_alias(self): + assert SseConnectionParams is McpSseServerParameters + + +class TestMcpConnectionParamsType: + def test_stdio_is_valid(self): + server_params = McpStdioServerParameters(command="npx") + conn = StdioConnectionParams(server_params=server_params) + assert isinstance(conn, StdioConnectionParams) + + def test_none_is_valid(self): + value: McpConnectionParamsType = None + assert value is None diff --git a/tests/tools/mcp_tool/test_utils.py b/tests/tools/mcp_tool/test_utils.py new file mode 100644 index 000000000..9365c8208 --- /dev/null +++ b/tests/tools/mcp_tool/test_utils.py @@ -0,0 +1,162 @@ +# 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. + +from unittest.mock import MagicMock, patch + +import pytest +from mcp import StdioServerParameters as McpStdioServerParameters + +from trpc_agent_sdk.tools.mcp_tool._types import ( + DEFAULT_TIMEOUT, + StdioConnectionParams, + SseConnectionParams, + StreamableHTTPConnectionParams, +) +from trpc_agent_sdk.tools.mcp_tool._utils import ( + convert_conn_params, + patch_mcp_cancel_scope_exit_issue, +) + + +class TestPatchMcpCancelScopeExitIssue: + """Tests for the patch_mcp_cancel_scope_exit_issue monkeypatch function.""" + + def test_patches_request_responder_exit(self): + """Verify __exit__ is replaced and the patched flag is set.""" + fake_responder = type("FakeRequestResponder", (), { + "__exit__": lambda self, exc_type, exc_val, exc_tb: None, + }) + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + + assert getattr(fake_responder, "_trpc_agent_patched_cancel_scope_exit", False) is True + assert fake_responder.__exit__.__name__ == "_patched_exit" + + def test_double_patch_is_noop(self): + """Calling twice should not re-patch.""" + call_count = 0 + original_exit = lambda self, et, ev, tb: None + + fake_responder = type("FakeRequestResponder", (), { + "__exit__": original_exit, + }) + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + first_exit = fake_responder.__exit__ + + patch_mcp_cancel_scope_exit_issue() + assert fake_responder.__exit__ is first_exit + + def test_patched_exit_swallows_cancel_scope_runtime_error(self): + """Cancel-scope RuntimeErrors during __exit__ should be silently caught.""" + + def bad_exit(self, exc_type, exc_val, exc_tb): + raise RuntimeError("Attempted to exit a cancel scope that isn't the current tasks's current cancel scope") + + fake_responder = type("FakeRequestResponder", (), { + "__exit__": bad_exit, + }) + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + + instance = fake_responder() + result = fake_responder.__exit__(instance, None, None, None) + assert result is None + + def test_patched_exit_reraises_other_runtime_errors(self): + """Non-cancel-scope RuntimeErrors should propagate normally.""" + + def bad_exit(self, exc_type, exc_val, exc_tb): + raise RuntimeError("Something completely different") + + fake_responder = type("FakeRequestResponder", (), { + "__exit__": bad_exit, + }) + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + + instance = fake_responder() + with pytest.raises(RuntimeError, match="Something completely different"): + fake_responder.__exit__(instance, None, None, None) + + def test_patched_exit_passes_through_normal_return(self): + """Normal __exit__ calls should work unchanged.""" + sentinel = object() + + def normal_exit(self, exc_type, exc_val, exc_tb): + return sentinel + + fake_responder = type("FakeRequestResponder", (), { + "__exit__": normal_exit, + }) + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + + instance = fake_responder() + result = fake_responder.__exit__(instance, None, None, None) + assert result is sentinel + + def test_no_request_responder_returns_early(self): + """If RequestResponder is not found, function should return without error.""" + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + mock_session.RequestResponder = None + patch_mcp_cancel_scope_exit_issue() + + def test_no_exit_method_returns_early(self): + """If __exit__ is not callable, function should return without error.""" + fake_responder = type("FakeRequestResponder", (), {}) + delattr(fake_responder, "__exit__") if hasattr(fake_responder, "__exit__") else None + + with patch("trpc_agent_sdk.tools.mcp_tool._utils.mcp_session") as mock_session: + fake_responder.__exit__ = "not_callable" + mock_session.RequestResponder = fake_responder + patch_mcp_cancel_scope_exit_issue() + assert not getattr(fake_responder, "_trpc_agent_patched_cancel_scope_exit", False) + + +class TestConvertConnParams: + """Tests for the convert_conn_params function.""" + + def test_stdio_server_params_converted_to_stdio_connection_params(self): + """McpStdioServerParameters should be wrapped in StdioConnectionParams.""" + server_params = McpStdioServerParameters(command="npx", args=["-y", "server"]) + result = convert_conn_params(server_params) + + assert isinstance(result, StdioConnectionParams) + assert result.server_params == server_params + assert result.timeout == DEFAULT_TIMEOUT + + def test_stdio_connection_params_passthrough(self): + """StdioConnectionParams should be returned as-is.""" + server_params = McpStdioServerParameters(command="npx") + conn = StdioConnectionParams(server_params=server_params, timeout=15.0) + result = convert_conn_params(conn) + + assert result is conn + + def test_none_passthrough(self): + """None should be returned as-is (it's valid McpConnectionParamsType).""" + result = convert_conn_params(None) + assert result is None + + def test_unsupported_type_raises_value_error(self): + """An unsupported type should raise ValueError.""" + with pytest.raises(ValueError, match="Unsupported connection parameters type"): + convert_conn_params({"url": "http://example.com"}) + + def test_unsupported_int_type_raises_value_error(self): + with pytest.raises(ValueError, match="Unsupported connection parameters type"): + convert_conn_params(42) diff --git a/tests/tools/task_tools/test_task_tools.py b/tests/tools/task_tools/test_task_tools.py new file mode 100644 index 000000000..51f8c15f0 --- /dev/null +++ b/tests/tools/task_tools/test_task_tools.py @@ -0,0 +1,327 @@ +# 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. +"""Tests for the Task tool family (:mod:`trpc_agent_sdk.tools.task_tools`).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from trpc_agent_sdk.agents._base_agent import BaseAgent +from trpc_agent_sdk.context import InvocationContext, create_agent_context +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools.task_tools import ( + DEFAULT_STATE_KEY_PREFIX, + TaskCreateTool, + TaskGetTool, + TaskListTool, + TaskStatus, + TaskStore, + TaskToolSet, + TaskUpdateTool, + decode_store, + detect_cycle, + get_task_store, + render_task_list, + state_key, +) +from trpc_agent_sdk.tools.task_tools._models import TaskRecord +from trpc_agent_sdk.types import Content, EventActions, Part + + +class _StubAgent(BaseAgent): + async def _run_async_impl(self, ctx): + yield + + +@pytest.fixture +def session_bundle(): + service = InMemorySessionService() + session = asyncio.run( + service.create_session(app_name="test", user_id="u1", session_id="s1") + ) + agent = _StubAgent(name="task_planner") + ctx = InvocationContext( + session_service=service, + invocation_id="inv-1", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + return service, session, agent, ctx + + +async def _create(ctx, subject, **kwargs): + tool = TaskCreateTool() + return await tool._run_async_impl(tool_context=ctx, args={"subject": subject, **kwargs}) + + +async def _update(ctx, task_id, **kwargs): + tool = TaskUpdateTool() + return await tool._run_async_impl(tool_context=ctx, args={"taskId": task_id, **kwargs}) + + +class TestTaskCreate: + @pytest.mark.asyncio + async def test_assigns_incrementing_id(self, session_bundle): + _, _, agent, ctx = session_bundle + first = await _create(ctx, "A") + second = await _create(ctx, "B") + assert first["task"]["id"] == "1" + assert second["task"]["id"] == "2" + assert state_key(DEFAULT_STATE_KEY_PREFIX, agent.name) in ctx.state._delta + + @pytest.mark.asyncio + async def test_rejects_empty_subject(self, session_bundle): + _, _, _, ctx = session_bundle + result = await _create(ctx, " ") + assert "error" in result + + @pytest.mark.asyncio + async def test_id_not_reused_after_delete(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _update(ctx, "1", status="deleted") + third = await _create(ctx, "C") + assert third["task"]["id"] == "2" + + +class TestTaskUpdate: + @pytest.mark.asyncio + async def test_status_transition(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + res = await _update(ctx, "1", status="in_progress") + assert res["task"]["status"] == "in_progress" + res = await _update(ctx, "1", status="completed") + assert res["task"]["status"] == "completed" + + @pytest.mark.asyncio + async def test_not_found(self, session_bundle): + _, _, _, ctx = session_bundle + res = await _update(ctx, "99", status="completed") + assert "NOT_FOUND" in res["error"] + + @pytest.mark.asyncio + async def test_single_in_progress_enforced(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _create(ctx, "B") + await _update(ctx, "1", status="in_progress") + res = await _update(ctx, "2", status="in_progress") + assert "error" in res + assert "in_progress" in res["error"] + + @pytest.mark.asyncio + async def test_single_in_progress_can_be_disabled(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _create(ctx, "B") + tool = TaskUpdateTool(enforce_single_in_progress=False) + await tool._run_async_impl(tool_context=ctx, args={"taskId": "1", "status": "in_progress"}) + res = await tool._run_async_impl(tool_context=ctx, args={"taskId": "2", "status": "in_progress"}) + assert "error" not in res + + @pytest.mark.asyncio + async def test_two_way_dependency_edges(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "schema") + await _create(ctx, "endpoints") + res = await _update(ctx, "2", addBlockedBy=["1"]) + assert res["task"]["blockedBy"] == ["1"] + store = get_task_store(ctx.session, branch="task_planner") + assert store.tasks["1"].blocks == ["2"] + + @pytest.mark.asyncio + async def test_complete_unblocks_downstream(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "schema") + await _create(ctx, "endpoints") + await _update(ctx, "2", addBlockedBy=["1"]) + res = await _update(ctx, "1", status="completed") + assert res["unblocked"] == ["2"] + store = get_task_store(ctx.session, branch="task_planner") + assert store.tasks["2"].blocked_by == [] + + @pytest.mark.asyncio + async def test_cycle_rejected(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _create(ctx, "B") + await _update(ctx, "2", addBlockedBy=["1"]) + res = await _update(ctx, "1", addBlockedBy=["2"]) + assert "INVALID_DEPENDENCY" in res["error"] + + @pytest.mark.asyncio + async def test_missing_dependency_rejected(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + res = await _update(ctx, "1", addBlockedBy=["99"]) + assert "INVALID_DEPENDENCY" in res["error"] + + @pytest.mark.asyncio + async def test_deleted_cannot_be_modified(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _update(ctx, "1", status="deleted") + res = await _update(ctx, "1", status="in_progress") + assert "error" in res + + +class TestTaskGetAndList: + @pytest.mark.asyncio + async def test_get_includes_description(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A", description="long detail") + res = await TaskGetTool()._run_async_impl(tool_context=ctx, args={"taskId": "1"}) + assert res["task"]["description"] == "long detail" + + @pytest.mark.asyncio + async def test_get_not_found(self, session_bundle): + _, _, _, ctx = session_bundle + res = await TaskGetTool()._run_async_impl(tool_context=ctx, args={"taskId": "1"}) + assert "NOT_FOUND" in res["error"] + + @pytest.mark.asyncio + async def test_list_omits_description_and_filters_deleted(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A", description="should not appear") + await _create(ctx, "B") + await _update(ctx, "2", status="deleted") + res = await TaskListTool()._run_async_impl(tool_context=ctx, args={}) + assert len(res["tasks"]) == 1 + assert "description" not in res["tasks"][0] + assert res["stats"]["pending"] == 1 + + @pytest.mark.asyncio + async def test_list_include_deleted(self, session_bundle): + _, _, _, ctx = session_bundle + await _create(ctx, "A") + await _update(ctx, "1", status="deleted") + res = await TaskListTool()._run_async_impl(tool_context=ctx, args={"includeDeleted": True}) + assert len(res["tasks"]) == 1 + + +class TestBranchIsolation: + @pytest.mark.asyncio + async def test_branches_are_independent(self, session_bundle): + service, session, agent, _ = session_bundle + ctx_a = InvocationContext( + session_service=service, invocation_id="i", agent=agent, + agent_context=create_agent_context(), session=session, branch="a", + ) + ctx_b = InvocationContext( + session_service=service, invocation_id="i", agent=agent, + agent_context=create_agent_context(), session=session, branch="b", + ) + await _create(ctx_a, "task in a") + await _create(ctx_b, "task in b") + store_a = decode_store(ctx_a.state._delta[state_key(DEFAULT_STATE_KEY_PREFIX, "a")]) + store_b = decode_store(ctx_b.state._delta[state_key(DEFAULT_STATE_KEY_PREFIX, "b")]) + assert store_a.tasks["1"].subject == "task in a" + assert store_b.tasks["1"].subject == "task in b" + + +class TestPersistence: + @pytest.mark.asyncio + async def test_store_survives_append_event_and_get_session(self, session_bundle): + service, session, agent, ctx = session_bundle + await _create(ctx, "A") + await _update(ctx, "1", status="in_progress") + + event = Event( + invocation_id="inv-1", + author=agent.name, + content=Content(parts=[Part.from_text(text="tool result")]), + actions=EventActions(state_delta=dict(ctx.event_actions.state_delta)), + ) + await service.append_event(session, event) + + stored = await service.get_session(app_name="test", user_id="u1", session_id="s1") + store = get_task_store(stored, branch=agent.name) + assert store.tasks["1"].status == TaskStatus.IN_PROGRESS + + +class TestConcurrency: + @pytest.mark.asyncio + async def test_parallel_create_assigns_unique_ids(self, session_bundle): + _, _, _, ctx = session_bundle + tool = TaskCreateTool() + results = await asyncio.gather( + *[ + tool._run_async_impl(tool_context=ctx, args={"subject": f"task-{i}"}) + for i in range(20) + ] + ) + ids = sorted(int(r["task"]["id"]) for r in results) + assert ids == list(range(1, 21)) + + @pytest.mark.asyncio + async def test_parallel_mixed_ops_preserve_all_tasks(self, session_bundle): + _, _, _, ctx = session_bundle + create = TaskCreateTool() + update = TaskUpdateTool() + await create._run_async_impl(tool_context=ctx, args={"subject": "seed"}) + await asyncio.gather( + create._run_async_impl(tool_context=ctx, args={"subject": "A"}), + create._run_async_impl(tool_context=ctx, args={"subject": "B"}), + update._run_async_impl(tool_context=ctx, args={"taskId": "1", "status": "in_progress"}), + ) + store = get_task_store(ctx.session, branch="task_planner") + assert set(store.tasks) == {"1", "2", "3"} + assert store.tasks["1"].status == TaskStatus.IN_PROGRESS + + +class TestHelpers: + def test_state_key(self): + assert state_key(DEFAULT_STATE_KEY_PREFIX, "") == "tasks" + assert state_key(DEFAULT_STATE_KEY_PREFIX, "planner") == "tasks:planner" + + def test_decode_dirty_data_degrades_to_empty(self): + assert decode_store("not json").tasks == {} + assert decode_store(None).tasks == {} + + def test_detect_cycle_on_clean_store(self): + store = TaskStore() + store.tasks["1"] = TaskRecord(id="1", subject="A") + store.tasks["2"] = TaskRecord(id="2", subject="B", blockedBy=["1"]) + assert detect_cycle(store) is None + + def test_render_task_list(self): + store = TaskStore() + store.tasks["1"] = TaskRecord(id="1", subject="Done", status=TaskStatus.COMPLETED) + store.tasks["2"] = TaskRecord( + id="2", subject="Active", activeForm="Doing active", status=TaskStatus.IN_PROGRESS + ) + store.tasks["3"] = TaskRecord(id="3", subject="Wait", blockedBy=["2"]) + rendered = render_task_list(store) + assert "✅ #1 Done" in rendered + assert "🔄 #2 Doing active" in rendered + assert "blocked by: 2" in rendered + +class TestProcessRequest: + @pytest.mark.asyncio + async def test_injects_prompt_once(self, session_bundle): + _, _, _, ctx = session_bundle + llm_request = LlmRequest() + await TaskCreateTool().process_request(tool_context=ctx, llm_request=llm_request) + await TaskUpdateTool().process_request(tool_context=ctx, llm_request=llm_request) + text = str(llm_request.config.system_instruction) + assert text.count("structured task board via the tools") == 1 + assert "task_create" in llm_request.tools_dict + assert "task_update" in llm_request.tools_dict + + +class TestTaskToolSet: + @pytest.mark.asyncio + async def test_returns_four_tools(self): + tools = await TaskToolSet().get_tools() + names = {t.name for t in tools} + assert names == {"task_create", "task_update", "task_get", "task_list"} diff --git a/tests/tools/test_agent_tool.py b/tests/tools/test_agent_tool.py new file mode 100644 index 000000000..467080769 --- /dev/null +++ b/tests/tools/test_agent_tool.py @@ -0,0 +1,395 @@ +# 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. + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.tools._agent_tool import AGENT_TOOL_APP_NAME_SUFFIX, AgentTool +from trpc_agent_sdk.types import Content, FunctionDeclaration, Part, Schema, Type + + +class InputSchema(BaseModel): + query: str + limit: int = 10 + + +class OutputSchema(BaseModel): + answer: str + confidence: float + + +class TestAgentToolInit: + + def test_basic_init(self): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "test_agent" + mock_agent.description = "A test agent" + + tool = AgentTool(agent=mock_agent) + assert tool.name == "test_agent" + assert tool.description == "A test agent" + assert tool.agent is mock_agent + assert tool.skip_summarization is False + + def test_init_with_skip_summarization(self): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent, skip_summarization=True) + assert tool.skip_summarization is True + + +class TestAgentToolGetDeclaration: + + def test_declaration_without_input_schema(self): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "basic_agent" + mock_agent.description = "Basic agent" + # Not an LlmAgent + mock_agent.__class__ = AgentABC + + tool = AgentTool(agent=mock_agent) + decl = tool._get_declaration() + + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "basic_agent" + assert decl.parameters.properties["request"].type == Type.STRING + assert decl.response.type == Type.STRING + + def test_declaration_with_input_schema(self): + from trpc_agent_sdk.agents import LlmAgent + + mock_agent = MagicMock(spec=LlmAgent) + mock_agent.name = "schema_agent" + mock_agent.description = "Schema agent" + mock_agent.input_schema = InputSchema + mock_agent.output_schema = None + + tool = AgentTool(agent=mock_agent) + decl = tool._get_declaration() + assert decl.name == "schema_agent" + assert decl.parameters is not None + + def test_declaration_with_output_schema(self): + from trpc_agent_sdk.agents import LlmAgent + + mock_agent = MagicMock(spec=LlmAgent) + mock_agent.name = "agent_out" + mock_agent.description = "desc" + mock_agent.input_schema = None + mock_agent.output_schema = OutputSchema + + tool = AgentTool(agent=mock_agent) + decl = tool._get_declaration() + assert decl.response.type == Type.OBJECT + + def test_declaration_without_output_schema(self): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent) + decl = tool._get_declaration() + assert decl.response.type == Type.STRING + + +class TestAgentToolRunAsyncImpl: + + @pytest.fixture + def mock_context(self): + ctx = MagicMock(spec=InvocationContext) + ctx.artifact_service = MagicMock() + ctx.state = MagicMock() + ctx.state.to_dict.return_value = {} + ctx.event_actions = MagicMock() + ctx.save_artifact = AsyncMock() + return ctx + + @pytest.mark.asyncio + async def test_run_basic_agent(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "test_agent" + mock_agent.description = "test" + + tool = AgentTool(agent=mock_agent) + + last_event = MagicMock(spec=Event) + last_event.content = Content(parts=[Part.from_text(text="Hello world")]) + last_event.actions = MagicMock() + last_event.actions.state_delta = {} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="session_id", user_id="tmp_user", app_name=f"test_agent{AGENT_TOOL_APP_NAME_SUFFIX}" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + yield last_event + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + result = await tool._run_async_impl( + args={"request": "Hello"}, + tool_context=mock_context, + ) + assert result == "Hello world" + mock_runner.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_run_returns_empty_on_no_event(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent) + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + return + yield # make it an async generator + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + result = await tool._run_async_impl( + args={"request": "Hello"}, + tool_context=mock_context, + ) + assert result == "" + + @pytest.mark.asyncio + async def test_run_with_skip_summarization(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent, skip_summarization=True) + + last_event = MagicMock(spec=Event) + last_event.content = Content(parts=[Part.from_text(text="result")]) + last_event.actions = MagicMock() + last_event.actions.state_delta = {} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + yield last_event + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + await tool._run_async_impl(args={"request": "Hi"}, tool_context=mock_context) + assert mock_context.event_actions.skip_summarization is True + + @pytest.mark.asyncio + async def test_run_forwards_state_delta(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent) + + event1 = MagicMock(spec=Event) + event1.content = Content(parts=[Part.from_text(text="r")]) + event1.actions = MagicMock() + event1.actions.state_delta = {"key": "value"} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + yield event1 + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + await tool._run_async_impl(args={"request": "test"}, tool_context=mock_context) + mock_context.state.update.assert_called_with({"key": "value"}) + + @pytest.mark.asyncio + async def test_run_raises_on_error(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent) + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + MockRunner.side_effect = RuntimeError("runner error") + + with pytest.raises(RuntimeError, match="runner error"): + await tool._run_async_impl(args={"request": "x"}, tool_context=mock_context) + + +class TestAgentToolRunAsyncWithInputSchema: + + @pytest.fixture + def mock_context(self): + ctx = MagicMock(spec=InvocationContext) + ctx.artifact_service = MagicMock() + ctx.state = MagicMock() + ctx.state.to_dict.return_value = {} + ctx.event_actions = MagicMock() + ctx.save_artifact = AsyncMock() + return ctx + + @pytest.mark.asyncio + async def test_run_with_input_schema(self, mock_context): + from trpc_agent_sdk.agents import LlmAgent + + mock_agent = MagicMock(spec=LlmAgent) + mock_agent.name = "schema_agent" + mock_agent.description = "desc" + mock_agent.input_schema = InputSchema + mock_agent.output_schema = None + + tool = AgentTool(agent=mock_agent) + + last_event = MagicMock(spec=Event) + last_event.content = Content(parts=[Part.from_text(text="result")]) + last_event.actions = MagicMock() + last_event.actions.state_delta = {} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + yield last_event + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + result = await tool._run_async_impl( + args={"query": "hello", "limit": 5}, + tool_context=mock_context, + ) + assert result == "result" + + @pytest.mark.asyncio + async def test_run_with_output_schema(self, mock_context): + from trpc_agent_sdk.agents import LlmAgent + + mock_agent = MagicMock(spec=LlmAgent) + mock_agent.name = "schema_agent" + mock_agent.description = "desc" + mock_agent.input_schema = None + mock_agent.output_schema = OutputSchema + + tool = AgentTool(agent=mock_agent) + + last_event = MagicMock(spec=Event) + last_event.content = Content(parts=[ + Part.from_text(text='{"answer": "hi", "confidence": 0.9}') + ]) + last_event.actions = MagicMock() + last_event.actions.state_delta = {} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + mock_runner.artifact_service = None + + async def mock_run_async(**kwargs): + yield last_event + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + result = await tool._run_async_impl( + args={"request": "test"}, + tool_context=mock_context, + ) + assert isinstance(result, dict) + assert result["answer"] == "hi" + + @pytest.mark.asyncio + async def test_run_with_artifact_forwarding(self, mock_context): + mock_agent = MagicMock(spec=AgentABC) + mock_agent.name = "agent" + mock_agent.description = "desc" + + tool = AgentTool(agent=mock_agent) + + last_event = MagicMock(spec=Event) + last_event.content = Content(parts=[Part.from_text(text="done")]) + last_event.actions = MagicMock() + last_event.actions.state_delta = {} + + with patch("trpc_agent_sdk.runners.Runner") as MockRunner: + mock_runner = AsyncMock() + MockRunner.return_value = mock_runner + mock_runner.session_service = AsyncMock() + mock_runner.session_service.create_session = AsyncMock(return_value=MagicMock( + id="sid", user_id="u", app_name="app" + )) + + mock_artifact_service = AsyncMock() + mock_artifact_service.list_artifact_keys = AsyncMock(return_value=["file.txt"]) + mock_artifact_service.load_artifact = AsyncMock(return_value=b"data") + mock_runner.artifact_service = mock_artifact_service + + async def mock_run_async(**kwargs): + yield last_event + + mock_runner.run_async = mock_run_async + mock_runner.close = AsyncMock() + + await tool._run_async_impl( + args={"request": "test"}, + tool_context=mock_context, + ) + mock_context.save_artifact.assert_awaited_once() + + +class TestAgentToolAppNameSuffix: + + def test_suffix_value(self): + assert AGENT_TOOL_APP_NAME_SUFFIX == "_trpc_agent_tool_" diff --git a/tests/tools/test_base_tool.py b/tests/tools/test_base_tool.py new file mode 100644 index 000000000..b46e5e3f0 --- /dev/null +++ b/tests/tools/test_base_tool.py @@ -0,0 +1,243 @@ +# 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. + +from __future__ import annotations + +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.tools._constants import DEFAULT_API_VARIANT +from trpc_agent_sdk.types import FunctionDeclaration, GenerateContentConfig, Schema, Tool, Type + + +class ConcreteTool(BaseTool): + """Minimal concrete implementation for testing.""" + + def __init__(self, name="test_tool", description="A test tool", **kwargs): + super().__init__(name=name, description=description, **kwargs) + self._run_result = "default_result" + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return self._run_result + + +class DeclarableTool(BaseTool): + """Tool that provides a function declaration.""" + + def __init__(self, name="declarable_tool", description="A declarable tool"): + super().__init__(name=name, description=description) + + @override + def _get_declaration(self) -> Optional[FunctionDeclaration]: + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema(type=Type.OBJECT, properties={"query": Schema(type=Type.STRING)}), + ) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return {} + + +class TestBaseToolInit: + + def test_basic_init(self): + tool = ConcreteTool(name="my_tool", description="desc") + assert tool.name == "my_tool" + assert tool.description == "desc" + + def test_name_property(self): + tool = ConcreteTool(name="test") + assert tool.name == "test" + + def test_is_streaming_default_false(self): + tool = ConcreteTool() + assert tool.is_streaming is False + + def test_api_variant_default(self): + tool = ConcreteTool() + assert tool.api_variant == DEFAULT_API_VARIANT + + def test_get_declaration_returns_none(self): + tool = ConcreteTool() + assert tool._get_declaration() is None + + +class TestBaseToolRunAsync: + + @pytest.fixture + def mock_tool_context(self): + ctx = MagicMock(spec=InvocationContext) + ctx.agent_context = MagicMock() + ctx.agent = MagicMock() + ctx.agent.before_tool_callback = None + ctx.agent.after_tool_callback = None + return ctx + + @pytest.mark.asyncio + async def test_run_async_basic(self, mock_tool_context): + tool = ConcreteTool() + tool._run_result = "hello" + with patch("trpc_agent_sdk.tools._base_tool.FilterRunner._run_filters", new_callable=AsyncMock) as mock_filters: + mock_filters.return_value = "hello" + result = await tool.run_async(tool_context=mock_tool_context, args={"key": "val"}) + assert result == "hello" + + @pytest.mark.asyncio + async def test_run_async_creates_agent_context_if_none(self): + ctx = MagicMock(spec=InvocationContext) + ctx.agent_context = None + ctx.agent = MagicMock() + ctx.agent.before_tool_callback = None + ctx.agent.after_tool_callback = None + + tool = ConcreteTool() + with patch("trpc_agent_sdk.tools._base_tool.FilterRunner._run_filters", new_callable=AsyncMock) as mock_filters: + mock_filters.return_value = "result" + with patch("trpc_agent_sdk.tools._base_tool.create_agent_context") as mock_create: + mock_create.return_value = MagicMock() + await tool.run_async(tool_context=ctx, args={}) + mock_create.assert_called_once() + + @pytest.mark.asyncio + async def test_run_async_sets_and_resets_tool_var(self, mock_tool_context): + tool = ConcreteTool() + with patch("trpc_agent_sdk.tools._base_tool.set_tool_var") as mock_set, \ + patch("trpc_agent_sdk.tools._base_tool.reset_tool_var") as mock_reset, \ + patch("trpc_agent_sdk.tools._base_tool.FilterRunner._run_filters", new_callable=AsyncMock): + mock_set.return_value = "token" + await tool.run_async(tool_context=mock_tool_context, args={}) + mock_set.assert_called_once_with(tool) + mock_reset.assert_called_once_with("token") + + @pytest.mark.asyncio + async def test_run_async_resets_tool_var_on_exception(self, mock_tool_context): + tool = ConcreteTool() + with patch("trpc_agent_sdk.tools._base_tool.set_tool_var") as mock_set, \ + patch("trpc_agent_sdk.tools._base_tool.reset_tool_var") as mock_reset, \ + patch("trpc_agent_sdk.tools._base_tool.FilterRunner._run_filters", + new_callable=AsyncMock, side_effect=RuntimeError("boom")): + mock_set.return_value = "token" + with pytest.raises(RuntimeError, match="boom"): + await tool.run_async(tool_context=mock_tool_context, args={}) + mock_reset.assert_called_once_with("token") + + +class TestFindToolWithFunctionDeclarations: + + def test_returns_none_when_no_config(self): + llm_request = MagicMock(spec=LlmRequest) + llm_request.config = None + assert BaseTool._find_tool_with_function_declarations(llm_request) is None + + def test_returns_none_when_no_tools(self): + llm_request = MagicMock(spec=LlmRequest) + llm_request.config = MagicMock() + llm_request.config.tools = None + assert BaseTool._find_tool_with_function_declarations(llm_request) is None + + def test_returns_none_when_empty_tools(self): + llm_request = MagicMock(spec=LlmRequest) + llm_request.config = MagicMock() + llm_request.config.tools = [] + assert BaseTool._find_tool_with_function_declarations(llm_request) is None + + def test_returns_tool_with_function_declarations(self): + tool = Tool(function_declarations=[FunctionDeclaration(name="fn")]) + llm_request = MagicMock(spec=LlmRequest) + llm_request.config = MagicMock() + llm_request.config.tools = [tool] + result = BaseTool._find_tool_with_function_declarations(llm_request) + assert result is tool + + def test_skips_tools_without_declarations(self): + tool_no_decl = Tool(function_declarations=None) + tool_with_decl = Tool(function_declarations=[FunctionDeclaration(name="fn")]) + llm_request = MagicMock(spec=LlmRequest) + llm_request.config = MagicMock() + llm_request.config.tools = [tool_no_decl, tool_with_decl] + result = BaseTool._find_tool_with_function_declarations(llm_request) + assert result is tool_with_decl + + +class TestProcessRequest: + + @pytest.mark.asyncio + async def test_no_declaration_does_nothing(self): + tool = ConcreteTool() + ctx = MagicMock(spec=InvocationContext) + llm_request = LlmRequest() + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not llm_request.tools_dict + + @pytest.mark.asyncio + async def test_adds_declaration_to_empty_request(self): + tool = DeclarableTool() + ctx = MagicMock(spec=InvocationContext) + llm_request = LlmRequest() + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert tool.name in llm_request.tools_dict + assert llm_request.config is not None + assert llm_request.config.tools is not None + assert len(llm_request.config.tools) == 1 + assert llm_request.config.tools[0].function_declarations[0].name == "declarable_tool" + + @pytest.mark.asyncio + async def test_appends_to_existing_tool_declarations(self): + tool = DeclarableTool() + ctx = MagicMock(spec=InvocationContext) + existing_decl = FunctionDeclaration(name="existing_fn") + existing_tool = Tool(function_declarations=[existing_decl]) + llm_request = LlmRequest(config=GenerateContentConfig(tools=[existing_tool])) + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + assert len(llm_request.config.tools) == 1 + assert len(llm_request.config.tools[0].function_declarations) == 2 + + @pytest.mark.asyncio + async def test_creates_config_if_none(self): + tool = DeclarableTool() + ctx = MagicMock(spec=InvocationContext) + llm_request = LlmRequest() + llm_request.config = None + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + assert llm_request.config is not None + assert llm_request.config.tools is not None + + @pytest.mark.asyncio + async def test_creates_tools_list_if_none(self): + tool = DeclarableTool() + ctx = MagicMock(spec=InvocationContext) + llm_request = LlmRequest(config=GenerateContentConfig()) + llm_request.config.tools = None + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + assert llm_request.config.tools is not None + assert len(llm_request.config.tools) == 1 + + @pytest.mark.asyncio + async def test_appends_to_tool_with_empty_declarations(self): + tool = DeclarableTool() + ctx = MagicMock(spec=InvocationContext) + existing_tool = Tool(function_declarations=None) + llm_request = LlmRequest(config=GenerateContentConfig(tools=[existing_tool])) + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + # Existing tool has no declarations, so a new Tool should be appended + assert len(llm_request.config.tools) == 2 diff --git a/tests/tools/test_constants.py b/tests/tools/test_constants.py new file mode 100644 index 000000000..f15503f26 --- /dev/null +++ b/tests/tools/test_constants.py @@ -0,0 +1,35 @@ +# 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. + +from trpc_agent_sdk.tools._constants import ( + DEFAULT_API_VARIANT, + DEFAULT_TOOLSET_NAME, + INPUT_STREAM, + TOOL_CONTEXT, + TOOL_NAME, +) + + +class TestConstants: + + def test_tool_context_value(self): + assert TOOL_CONTEXT == "tool_context" + + def test_input_stream_value(self): + assert INPUT_STREAM == "input_stream" + + def test_default_api_variant_value(self): + assert DEFAULT_API_VARIANT == "default" + + def test_default_toolset_name_value(self): + assert DEFAULT_TOOLSET_NAME == "default" + + def test_tool_name_value(self): + assert TOOL_NAME == "set_model_response" + + def test_constants_are_strings(self): + for const in [TOOL_CONTEXT, INPUT_STREAM, DEFAULT_API_VARIANT, DEFAULT_TOOLSET_NAME, TOOL_NAME]: + assert isinstance(const, str) diff --git a/tests/tools/test_context_var.py b/tests/tools/test_context_var.py new file mode 100644 index 000000000..88fdddda6 --- /dev/null +++ b/tests/tools/test_context_var.py @@ -0,0 +1,57 @@ +# 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. + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.abc import ToolABC +from trpc_agent_sdk.tools._context_var import get_tool_var, reset_tool_var, set_tool_var + + +class TestContextVar: + + def test_set_and_get_tool_var(self): + mock_tool = MagicMock(spec=ToolABC) + token = set_tool_var(mock_tool) + try: + result = get_tool_var() + assert result is mock_tool + finally: + reset_tool_var(token) + + def test_reset_restores_previous(self): + mock_tool_1 = MagicMock(spec=ToolABC) + mock_tool_2 = MagicMock(spec=ToolABC) + + token_1 = set_tool_var(mock_tool_1) + assert get_tool_var() is mock_tool_1 + + token_2 = set_tool_var(mock_tool_2) + assert get_tool_var() is mock_tool_2 + + reset_tool_var(token_2) + assert get_tool_var() is mock_tool_1 + + reset_tool_var(token_1) + + def test_reset_with_invalid_token_raises(self): + mock_tool = MagicMock(spec=ToolABC) + token = set_tool_var(mock_tool) + reset_tool_var(token) + # Second reset with the same (used) token raises RuntimeError + # The source only catches ValueError, not RuntimeError + with pytest.raises(RuntimeError): + reset_tool_var(token) + + def test_get_default_is_none(self): + # In a fresh context, the default should be None + # We need to set and reset to verify the default behavior + mock_tool = MagicMock(spec=ToolABC) + token = set_tool_var(mock_tool) + reset_tool_var(token) + result = get_tool_var() + assert result is None diff --git a/tests/tools/test_default_toolset.py b/tests/tools/test_default_toolset.py new file mode 100644 index 000000000..37327fd51 --- /dev/null +++ b/tests/tools/test_default_toolset.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. + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.tools._default_toolset import DefaultToolSet +from trpc_agent_sdk.tools._function_tool import FunctionTool + + +class DummyTool(BaseTool): + + def __init__(self, name="dummy", description="dummy"): + super().__init__(name=name, description=description) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return {} + + +def sample_func(x: str) -> str: + """Sample function.""" + return x + + +class TestDefaultToolSetInit: + + def test_init(self): + ts = DefaultToolSet() + ts.initialize() + assert ts._tool_filter == [] + + +class TestDefaultToolSetAddTools: + + def setup_method(self): + self.toolset = DefaultToolSet() + self.toolset.initialize() + + def test_add_base_tool(self): + tool = DummyTool(name="t1") + self.toolset.add_tools([tool]) + assert len(self.toolset._tool_filter) == 1 + assert self.toolset._tool_filter[0] is tool + + def test_add_callable(self): + self.toolset.add_tools([sample_func]) + assert len(self.toolset._tool_filter) == 1 + assert isinstance(self.toolset._tool_filter[0], FunctionTool) + + def test_add_string_tool_found(self): + with patch("trpc_agent_sdk.tools._default_toolset.get_tool") as mock_get: + mock_tool = DummyTool(name="from_registry") + mock_get.return_value = mock_tool + self.toolset.add_tools(["from_registry"]) + assert len(self.toolset._tool_filter) == 1 + assert self.toolset._tool_filter[0] is mock_tool + + def test_add_string_tool_not_found_logs_warning(self): + with patch("trpc_agent_sdk.tools._default_toolset.get_tool", return_value=None), \ + patch("trpc_agent_sdk.tools._default_toolset.logger") as mock_logger: + self.toolset.add_tools(["missing_tool"]) + mock_logger.warning.assert_called_once() + assert len(self.toolset._tool_filter) == 0 + + def test_add_unsupported_type_raises(self): + with pytest.raises(ValueError, match="Unsupported tool type"): + self.toolset.add_tools([12345]) + + def test_add_multiple_tools(self): + tool = DummyTool(name="t1") + self.toolset.add_tools([tool, sample_func]) + assert len(self.toolset._tool_filter) == 2 + + +class TestDefaultToolSetGetTools: + + def setup_method(self): + self.toolset = DefaultToolSet() + self.toolset.initialize() + + @pytest.mark.asyncio + async def test_get_tools_empty(self): + tools = await self.toolset.get_tools() + assert tools == [] + + @pytest.mark.asyncio + async def test_get_tools_returns_all(self): + t1 = DummyTool(name="t1") + t2 = DummyTool(name="t2") + self.toolset.add_tools([t1, t2]) + + with patch.object(self.toolset, "_is_tool_selected", return_value=True): + tools = await self.toolset.get_tools() + assert len(tools) == 2 + + @pytest.mark.asyncio + async def test_get_tools_filters_by_selection(self): + t1 = DummyTool(name="selected") + t2 = DummyTool(name="not_selected") + self.toolset.add_tools([t1, t2]) + + def select_only_selected(tool, ctx): + return tool.name == "selected" + + with patch.object(self.toolset, "_is_tool_selected", side_effect=select_only_selected): + tools = await self.toolset.get_tools() + assert len(tools) == 1 + assert tools[0].name == "selected" + + +class TestDefaultToolSetClose: + + @pytest.mark.asyncio + async def test_close_is_noop(self): + ts = DefaultToolSet() + ts.initialize() + await ts.close() # Should not raise diff --git a/tests/tools/test_file_utils.py b/tests/tools/test_file_utils.py new file mode 100644 index 000000000..2b015baa3 --- /dev/null +++ b/tests/tools/test_file_utils.py @@ -0,0 +1,71 @@ +# 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 file_tools/_file_utils.py.""" + +import os +import tempfile + +from trpc_agent_sdk.tools.file_tools._file_utils import ( + _detect_encoding, + _detect_encoding_fallback, + safe_read_file, +) + + +class TestDetectEncoding: + """Test suite for _detect_encoding.""" + + def test_utf8_file(self, tmp_path): + """Test UTF-8 file detected.""" + p = tmp_path / "test.txt" + p.write_text("hello world", encoding="utf-8") + result = _detect_encoding(str(p)) + assert result.lower().replace("-", "").replace("_", "") in ("utf8", "ascii") + + def test_empty_file(self, tmp_path): + """Test empty file returns utf-8.""" + p = tmp_path / "empty.txt" + p.write_bytes(b"") + assert _detect_encoding(str(p)) == "utf-8" + + def test_nonexistent_file(self): + """Test nonexistent file returns utf-8 via fallback.""" + result = _detect_encoding("/nonexistent/path/12345") + assert result == "utf-8" + + +class TestDetectEncodingFallback: + """Test suite for _detect_encoding_fallback.""" + + def test_utf8_content(self, tmp_path): + """Test UTF-8 content detected.""" + p = tmp_path / "test.txt" + p.write_bytes("hello world".encode("utf-8")) + assert _detect_encoding_fallback(str(p)) == "utf-8" + + def test_nonexistent_returns_utf8(self): + """Test nonexistent file returns utf-8.""" + result = _detect_encoding_fallback("/nonexistent/path/12345") + assert result == "utf-8" + + +class TestSafeReadFile: + """Test suite for safe_read_file.""" + + def test_read_utf8(self, tmp_path): + """Test reading UTF-8 file.""" + p = tmp_path / "test.txt" + p.write_text("hello world", encoding="utf-8") + content, enc = safe_read_file(str(p)) + assert content == "hello world" + assert enc == "utf-8" + + def test_read_with_encoding_fallback(self, tmp_path): + """Test reading with encoding detection fallback.""" + p = tmp_path / "test.txt" + p.write_bytes("hello".encode("utf-8")) + content, enc = safe_read_file(str(p), encoding="utf-8") + assert content == "hello" diff --git a/tests/tools/test_function_tool.py b/tests/tools/test_function_tool.py new file mode 100644 index 000000000..c550ba984 --- /dev/null +++ b/tests/tools/test_function_tool.py @@ -0,0 +1,223 @@ +# 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. + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._function_tool import FunctionTool + + +# --- Test functions --- + +def sync_func(param1: str, param2: int = 10) -> str: + """A sync test function.""" + return f"{param1}-{param2}" + + +async def async_func(param1: str) -> str: + """An async test function.""" + return f"async-{param1}" + + +def func_with_context(query: str, tool_context: InvocationContext) -> str: + """Function requiring tool context.""" + return f"query={query}" + + +def func_no_doc(x: int): + pass + + +class CallableObj: + """A callable object.""" + + def __call__(self, value: str) -> str: + """Call doc.""" + return value + + +class CallableObjNoCallDoc: + """Object doc.""" + + def __call__(self, value: str) -> str: + return value + + +class OutputModel(BaseModel): + name: str + value: int + + +def func_returns_model(name: str, value: int) -> OutputModel: + """Returns a pydantic model.""" + return OutputModel(name=name, value=value) + + +class TestFunctionToolInit: + + def test_init_with_function(self): + tool = FunctionTool(sync_func) + assert tool.name == "sync_func" + assert tool.description == "A sync test function." + assert tool.func is sync_func + + def test_init_with_async_function(self): + tool = FunctionTool(async_func) + assert tool.name == "async_func" + assert tool.description == "An async test function." + + def test_init_with_callable_object(self): + obj = CallableObj() + tool = FunctionTool(obj) + assert tool.name == "CallableObj" + assert tool.description == "Call doc." + + def test_init_with_callable_object_no_call_doc(self): + obj = CallableObjNoCallDoc() + tool = FunctionTool(obj) + assert tool.name == "CallableObjNoCallDoc" + assert tool.description == "Object doc." + + def test_init_with_no_doc(self): + tool = FunctionTool(func_no_doc) + assert tool.name == "func_no_doc" + assert tool.description == "" + + def test_init_with_filters_name_invalid_raises(self): + with pytest.raises(ValueError, match="not found"): + FunctionTool(sync_func, filters_name=["nonexistent_filter"]) + + +class TestFunctionToolGetDeclaration: + + def test_get_declaration_basic(self): + tool = FunctionTool(sync_func) + decl = tool._get_declaration() + assert decl is not None + assert decl.name == "sync_func" + + def test_get_declaration_ignores_tool_context(self): + tool = FunctionTool(func_with_context) + decl = tool._get_declaration() + assert decl is not None + if decl.parameters and decl.parameters.properties: + assert "tool_context" not in decl.parameters.properties + + +class TestFunctionToolRunAsync: + + @pytest.fixture + def mock_context(self): + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + ctx.agent.parallel_tool_calls = False + return ctx + + @pytest.mark.asyncio + async def test_run_sync_function(self, mock_context): + tool = FunctionTool(sync_func) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"param1": "hello", "param2": 5}, + ) + assert result == "hello-5" + + @pytest.mark.asyncio + async def test_run_async_function(self, mock_context): + tool = FunctionTool(async_func) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"param1": "world"}, + ) + assert result == "async-world" + + @pytest.mark.asyncio + async def test_injects_tool_context(self, mock_context): + tool = FunctionTool(func_with_context) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"query": "test"}, + ) + assert result == "query=test" + + @pytest.mark.asyncio + async def test_missing_mandatory_args(self, mock_context): + tool = FunctionTool(sync_func) + result = await tool._run_async_impl( + tool_context=mock_context, + args={}, + ) + assert isinstance(result, dict) + assert "error" in result + assert "param1" in result["error"] + + @pytest.mark.asyncio + async def test_returns_empty_dict_for_none(self, mock_context): + def returns_none(): + return None + + tool = FunctionTool(returns_none) + result = await tool._run_async_impl( + tool_context=mock_context, + args={}, + ) + assert result == {} + + @pytest.mark.asyncio + async def test_pydantic_model_return(self, mock_context): + tool = FunctionTool(func_returns_model) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"name": "test", "value": 42}, + ) + assert isinstance(result, str) + assert "test" in result + assert "42" in result + + @pytest.mark.asyncio + async def test_parallel_tool_calls_uses_thread(self, mock_context): + mock_context.agent.parallel_tool_calls = True + + def slow_func(x: str) -> str: + return f"done-{x}" + + tool = FunctionTool(slow_func) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"x": "val"}, + ) + assert result == "done-val" + + @pytest.mark.asyncio + async def test_async_callable_object(self, mock_context): + class AsyncCallable: + async def __call__(self, value: str) -> str: + """Async callable.""" + return f"async-{value}" + + obj = AsyncCallable() + tool = FunctionTool(obj) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"value": "test"}, + ) + assert result == "async-test" + + @pytest.mark.asyncio + async def test_default_param_not_required(self, mock_context): + tool = FunctionTool(sync_func) + result = await tool._run_async_impl( + tool_context=mock_context, + args={"param1": "hello"}, + ) + assert result == "hello-10" diff --git a/tests/tools/test_load_memory_tool.py b/tests/tools/test_load_memory_tool.py new file mode 100644 index 000000000..7114f2048 --- /dev/null +++ b/tests/tools/test_load_memory_tool.py @@ -0,0 +1,103 @@ +# 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. + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._load_memory_tool import ( + LoadMemoryResponse, + LoadMemoryTool, + load_memory, + load_memory_tool, +) +from trpc_agent_sdk.types import FunctionDeclaration, MemoryEntry, Schema, Type + + +class TestLoadMemoryResponse: + + def test_default_empty(self): + rsp = LoadMemoryResponse() + assert rsp.memories == [] + + def test_with_memories(self): + entry = MagicMock(spec=MemoryEntry) + rsp = LoadMemoryResponse(memories=[entry]) + assert len(rsp.memories) == 1 + + +class TestLoadMemoryFunction: + + @pytest.mark.asyncio + async def test_load_memory_returns_json(self): + mock_ctx = MagicMock(spec=InvocationContext) + mock_response = MagicMock() + mock_response.memories = [] + mock_ctx.search_memory = AsyncMock(return_value=mock_response) + + result = await load_memory("test query", mock_ctx) + parsed = json.loads(result) + assert "memories" in parsed + + @pytest.mark.asyncio + async def test_load_memory_with_results(self): + mock_ctx = MagicMock(spec=InvocationContext) + mock_entry = MagicMock(spec=MemoryEntry) + mock_response = MagicMock() + mock_response.memories = [mock_entry] + mock_ctx.search_memory = AsyncMock(return_value=mock_response) + + result = await load_memory("query", mock_ctx) + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_load_memory_calls_search_memory(self): + mock_ctx = MagicMock(spec=InvocationContext) + mock_response = MagicMock() + mock_response.memories = [] + mock_ctx.search_memory = AsyncMock(return_value=mock_response) + + await load_memory("my_query", mock_ctx) + mock_ctx.search_memory.assert_awaited_once_with("my_query") + + +class TestLoadMemoryTool: + + def test_init(self): + tool = LoadMemoryTool() + assert tool.name == "load_memory" + + def test_get_declaration(self): + tool = LoadMemoryTool() + decl = tool._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "load_memory" + assert decl.parameters.type == Type.OBJECT + assert "query" in decl.parameters.properties + + @pytest.mark.asyncio + async def test_process_request_adds_instructions(self): + tool = LoadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + assert tool.name in llm_request.tools_dict + assert llm_request.config is not None + assert llm_request.config.system_instruction is not None + assert "memory" in llm_request.config.system_instruction.lower() + + +class TestLoadMemoryToolSingleton: + + def test_module_level_instance(self): + assert isinstance(load_memory_tool, LoadMemoryTool) diff --git a/tests/tools/test_long_running_tool.py b/tests/tools/test_long_running_tool.py new file mode 100644 index 000000000..a32af2126 --- /dev/null +++ b/tests/tools/test_long_running_tool.py @@ -0,0 +1,57 @@ +# 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. + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.tools._long_running_tool import LongRunningFunctionTool + + +def long_func(query: str) -> str: + """A long running function.""" + return f"result-{query}" + + +def long_func_no_doc(query: str) -> str: + return f"result-{query}" + + +long_func_no_doc.__doc__ = None + + +class TestLongRunningFunctionToolInit: + + def test_init(self): + tool = LongRunningFunctionTool(long_func) + assert tool.is_long_running is True + assert tool.name == "long_func" + + def test_init_with_invalid_filters_raises(self): + with pytest.raises(ValueError, match="not found"): + LongRunningFunctionTool(long_func, filters_name=["nonexistent"]) + + +class TestLongRunningFunctionToolGetDeclaration: + + def test_declaration_appends_note(self): + tool = LongRunningFunctionTool(long_func) + decl = tool._get_declaration() + assert decl is not None + assert "long-running operation" in decl.description + assert "A long running function." in decl.description + + def test_declaration_with_no_existing_description(self): + tool = LongRunningFunctionTool(long_func_no_doc) + decl = tool._get_declaration() + assert decl is not None + # When description is empty string '', the instruction is set with lstrip + assert "long-running operation" in (decl.description or "") + + def test_declaration_contains_do_not_call_again(self): + tool = LongRunningFunctionTool(long_func) + decl = tool._get_declaration() + assert "Do not call this tool again" in decl.description diff --git a/tests/tools/test_mem0_tool.py b/tests/tools/test_mem0_tool.py new file mode 100644 index 000000000..f9e3a8648 --- /dev/null +++ b/tests/tools/test_mem0_tool.py @@ -0,0 +1,151 @@ +# 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. + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import FunctionDeclaration, Type + +try: + import mem0 # noqa: F401 + HAS_MEM0 = True +except ImportError: + HAS_MEM0 = False + +pytestmark = pytest.mark.skipif(not HAS_MEM0, reason="mem0 not installed") + + +class TestSearchMemoryTool: + + @pytest.fixture + def mock_client(self): + client = AsyncMock() + return client + + @pytest.fixture + def tool(self, mock_client): + from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool + return SearchMemoryTool(client=mock_client) + + def test_init(self, tool): + assert tool.name == "search_memory" + assert tool.description == "Search through past conversations and memories" + + def test_init_with_extra_kwargs(self, mock_client): + from trpc_agent_sdk.tools.mem0_tool import SearchMemoryTool + tool = SearchMemoryTool(client=mock_client, top_k=5) + assert tool.kwargs == {"top_k": 5} + + def test_get_declaration(self, tool): + decl = tool._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "search_memory" + assert decl.parameters.type == Type.OBJECT + assert "query" in decl.parameters.properties + assert decl.parameters.required == ["query"] + + @pytest.mark.asyncio + async def test_run_with_results(self, tool, mock_client): + ctx = MagicMock(spec=InvocationContext) + ctx.user_id = "user_1" + mock_client.search = AsyncMock(return_value={ + "results": [ + {"memory": "I like Python"}, + {"memory": "I work at ACME"}, + ] + }) + + result = await tool._run_async_impl(tool_context=ctx, args={"query": "preferences"}) + assert result["status"] == "success" + assert "I like Python" in result["memories"] + assert result["user_id"] == "user_1" + + @pytest.mark.asyncio + async def test_run_no_results(self, tool, mock_client): + ctx = MagicMock(spec=InvocationContext) + ctx.user_id = "user_1" + mock_client.search = AsyncMock(return_value={"results": []}) + + result = await tool._run_async_impl(tool_context=ctx, args={"query": "something"}) + assert result["status"] == "no_memories" + + @pytest.mark.asyncio + async def test_run_empty_results_key(self, tool, mock_client): + ctx = MagicMock(spec=InvocationContext) + ctx.user_id = "user_1" + mock_client.search = AsyncMock(return_value={}) + + result = await tool._run_async_impl(tool_context=ctx, args={"query": "q"}) + assert result["status"] == "no_memories" + + +class TestSaveMemoryTool: + + @pytest.fixture + def mock_client(self): + return AsyncMock() + + @pytest.fixture + def tool(self, mock_client): + from trpc_agent_sdk.tools.mem0_tool import SaveMemoryTool + return SaveMemoryTool(client=mock_client) + + def test_init(self, tool): + assert tool.name == "save_memory" + assert tool.description == "Save important information to memory" + + def test_init_sets_infer_default(self, tool): + assert tool.kwargs.get("infer") is True + + def test_init_preserves_custom_infer(self, mock_client): + from trpc_agent_sdk.tools.mem0_tool import SaveMemoryTool + tool = SaveMemoryTool(client=mock_client, infer=False) + assert tool.kwargs["infer"] is False + + def test_get_declaration(self, tool): + decl = tool._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "save_memory" + assert decl.parameters.type == Type.OBJECT + assert "content" in decl.parameters.properties + assert decl.parameters.required == ["content"] + + @pytest.mark.asyncio + async def test_run_success(self, tool, mock_client): + ctx = MagicMock(spec=InvocationContext) + ctx.user_id = "user_1" + mock_client.add = AsyncMock(return_value={"id": "mem_1"}) + + result = await tool._run_async_impl( + tool_context=ctx, + args={"content": "Remember this"}, + ) + assert result["status"] == "success" + assert result["user_id"] == "user_1" + mock_client.add.assert_awaited_once() + + @pytest.mark.asyncio + async def test_run_error(self, tool, mock_client): + ctx = MagicMock(spec=InvocationContext) + ctx.user_id = "user_1" + mock_client.add = AsyncMock(side_effect=RuntimeError("API error")) + + result = await tool._run_async_impl( + tool_context=ctx, + args={"content": "data"}, + ) + assert result["status"] == "error" + assert "API error" in result["message"] + assert result["user_id"] == "user_1" + + def test_init_with_invalid_filters_raises(self, mock_client): + from trpc_agent_sdk.tools.mem0_tool import SaveMemoryTool + with pytest.raises(ValueError, match="not found"): + SaveMemoryTool(client=mock_client, filters_name=["nonexistent"]) diff --git a/tests/tools/test_mempalace_tool.py b/tests/tools/test_mempalace_tool.py new file mode 100644 index 000000000..0b92de200 --- /dev/null +++ b/tests/tools/test_mempalace_tool.py @@ -0,0 +1,390 @@ +# 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 MemPalace tools.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +import trpc_agent_sdk.tools.mempalace_tool as mempalace_tool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceAddDrawerTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceDiaryReadTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceDiaryWriteTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGAddTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGInvalidateTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGQueryTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceKGTimelineTool +from trpc_agent_sdk.tools.mempalace_tool import MempalaceSearchTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Type + + +def _ctx() -> MagicMock: + ctx = MagicMock(spec=InvocationContext) + ctx.agent_name = "test_agent" + return ctx + + +class FakeCollection: + def __init__(self, get_result: dict | None = None) -> None: + self.get_result = get_result or {"ids": []} + self.get_calls = [] + self.upserts = [] + self.adds = [] + + def get(self, **kwargs): + self.get_calls.append(kwargs) + return self.get_result + + def upsert(self, **kwargs): + self.upserts.append(kwargs) + + def add(self, **kwargs): + self.adds.append(kwargs) + + +class FakeConfig: + palace_path = "/default/palace" + collection_name = "default_collection" + + +class TestMempalaceBaseTool: + def test_get_palace_path_uses_config_default(self, monkeypatch): + monkeypatch.setattr(mempalace_tool, "MempalaceConfig", lambda: FakeConfig()) + + assert MempalaceSearchTool()._get_palace_path() == "/default/palace" + + def test_get_collection_uses_config_and_create_flag(self, monkeypatch): + calls = [] + collection = FakeCollection() + + def fake_get_collection(palace_path, collection_name, create): + calls.append((palace_path, collection_name, create)) + return collection + + monkeypatch.setattr(mempalace_tool, "MempalaceConfig", lambda: FakeConfig()) + monkeypatch.setattr(mempalace_tool, "get_collection", fake_get_collection) + + assert MempalaceSearchTool()._get_collection(create=True) is collection + assert calls == [("/default/palace", "default_collection", True)] + + def test_get_knowledge_graph_uses_palace_path_default(self, monkeypatch, tmp_path): + calls = [] + + def fake_knowledge_graph(db_path): + calls.append(db_path) + return "kg" + + monkeypatch.setattr(mempalace_tool, "KnowledgeGraph", fake_knowledge_graph) + tool = MempalaceKGQueryTool(palace_path=str(tmp_path)) + + assert tool._get_knowledge_graph() == "kg" + assert calls == [str(tmp_path / "knowledge_graph.sqlite3")] + + async def test_run_in_thread_handles_import_error(self): + tool = MempalaceSearchTool() + + def raise_import_error(): + raise ImportError("missing mempalace") + + result = await tool._run_in_thread(raise_import_error) + + assert result["success"] is False + assert "MemPalace is not installed" in result["error"] + + async def test_run_in_thread_handles_generic_error(self): + tool = MempalaceSearchTool() + + def raise_error(): + raise RuntimeError("boom") + + assert await tool._run_in_thread(raise_error) == {"success": False, "error": "boom"} + + +class TestMempalaceToolDeclarations: + @pytest.mark.parametrize( + ("tool", "name"), + [ + (MempalaceSearchTool(), "mempalace_search"), + (MempalaceAddDrawerTool(), "mempalace_add_drawer"), + (MempalaceDiaryWriteTool(), "mempalace_diary_write"), + (MempalaceDiaryReadTool(), "mempalace_diary_read"), + (MempalaceKGQueryTool(), "mempalace_kg_query"), + (MempalaceKGAddTool(), "mempalace_kg_add"), + (MempalaceKGInvalidateTool(), "mempalace_kg_invalidate"), + (MempalaceKGTimelineTool(), "mempalace_kg_timeline"), + ], + ) + def test_declaration(self, tool, name): + decl = tool._get_declaration() + + assert isinstance(decl, FunctionDeclaration) + assert decl.name == name + assert decl.parameters.type == Type.OBJECT + + +class TestMempalaceSearchTool: + async def test_run_searches_with_filters(self, monkeypatch): + calls = [] + + def fake_search_memories(**kwargs): + calls.append(kwargs) + return {"query": kwargs["query"], "results": []} + + monkeypatch.setattr(mempalace_tool, "search_memories", fake_search_memories) + tool = MempalaceSearchTool(palace_path="/custom/palace") + + result = await tool._run_async_impl( + tool_context=_ctx(), + args={"query": "hello", "limit": "3", "wing": " wing-a ", "room": " "}, + ) + + assert result == {"query": "hello", "results": []} + assert calls == [{ + "query": "hello", + "palace_path": "/custom/palace", + "wing": "wing_a", + "room": None, + "n_results": 3, + }] + + +class TestMempalaceAddDrawerTool: + async def test_add_drawer_upserts_new_drawer(self, monkeypatch): + collection = FakeCollection(get_result={"ids": []}) + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceAddDrawerTool(palace_path="/p", added_by="tests")._run_async_impl( + tool_context=_ctx(), + args={ + "wing": "Personal Assistant", + "room": "User Profile", + "content": "User's name is Alice.", + "source_file": "demo.txt", + }, + ) + + assert result["success"] is True + assert result["wing"] == "personal_assistant" + assert result["room"] == "user_profile" + assert collection.upserts[0]["documents"] == ["User's name is Alice."] + assert collection.upserts[0]["metadatas"][0]["added_by"] == "tests" + assert collection.upserts[0]["metadatas"][0]["source_file"] == "demo.txt" + + async def test_add_drawer_returns_existing_drawer(self, monkeypatch): + collection = FakeCollection(get_result={"ids": ["existing-id"]}) + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceAddDrawerTool(palace_path="/p")._run_async_impl( + tool_context=_ctx(), + args={"wing": "wing", "room": "room", "content": "content"}, + ) + + assert result["success"] is True + assert result["reason"] == "already_exists" + assert collection.upserts == [] + + +class TestMempalaceDiaryTools: + async def test_diary_write_uses_default_agent_scope(self, monkeypatch): + collection = FakeCollection() + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceDiaryWriteTool(palace_path="/p")._run_async_impl( + tool_context=_ctx(), + args={"entry": "Finished the MemPalace example.", "topic": "daily notes"}, + ) + + assert result["success"] is True + assert result["agent"] == "test_agent" + assert result["topic"] == "daily_notes" + assert collection.adds[0]["documents"] == ["Finished the MemPalace example."] + assert collection.adds[0]["metadatas"][0]["wing"] == "wing_test_agent" + assert collection.adds[0]["metadatas"][0]["room"] == "diary" + + async def test_diary_write_uses_explicit_agent_and_wing(self, monkeypatch): + collection = FakeCollection() + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceDiaryWriteTool(palace_path="/p")._run_async_impl( + tool_context=_ctx(), + args={"agent_name": "Research Bot", "entry": "entry", "wing": "Project Wing"}, + ) + + assert result["agent"] == "research_bot" + assert collection.adds[0]["metadatas"][0]["wing"] == "project_wing" + + async def test_diary_read_returns_message_when_empty(self, monkeypatch): + collection = FakeCollection(get_result={"ids": []}) + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceDiaryReadTool(palace_path="/p")._run_async_impl(tool_context=_ctx(), args={}) + + assert result["agent"] == "test_agent" + assert result["entries"] == [] + assert result["message"] == "No diary entries yet." + + async def test_diary_read_sorts_and_limits_entries(self, monkeypatch): + collection = FakeCollection(get_result={ + "ids": ["old", "new"], + "documents": ["old content", "new content"], + "metadatas": [ + {"date": "2026-01-01", "filed_at": "2026-01-01T00:00:00", "topic": "old"}, + {"date": "2026-01-02", "filed_at": "2026-01-02T00:00:00", "topic": "new"}, + ], + }) + monkeypatch.setattr(mempalace_tool, "get_collection", lambda *args, **kwargs: collection) + + result = await MempalaceDiaryReadTool(palace_path="/p")._run_async_impl( + tool_context=_ctx(), + args={"last_n": 1, "wing": "Project Wing"}, + ) + + assert result["total"] == 2 + assert result["showing"] == 1 + assert result["entries"][0]["content"] == "new content" + assert collection.get_calls[0]["where"]["$and"][0] == {"wing": "project_wing"} + + +class TestMempalaceKGTools: + def test_kg_tool_explicit_path(self, monkeypatch): + calls = [] + + def fake_knowledge_graph(db_path): + calls.append(db_path) + return MagicMock() + + monkeypatch.setattr(mempalace_tool, "KnowledgeGraph", fake_knowledge_graph) + + MempalaceKGQueryTool(kg_path="/explicit/kg.sqlite3")._get_knowledge_graph() + + assert calls == ["/explicit/kg.sqlite3"] + + async def test_kg_query(self, monkeypatch): + kg = MagicMock() + kg.query_entity.return_value = [{"subject": "Alice", "predicate": "works_on", "object": "TRPC"}] + tool = MempalaceKGQueryTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + result = await tool._run_async_impl( + tool_context=_ctx(), + args={"entity": "Alice", "direction": "both"}, + ) + + assert result["count"] == 1 + kg.query_entity.assert_called_once_with("Alice", as_of=None, direction="both") + + async def test_kg_query_rejects_invalid_direction(self): + result = await MempalaceKGQueryTool()._run_async_impl( + tool_context=_ctx(), + args={"entity": "Alice", "direction": "sideways"}, + ) + + assert result == {"error": "direction must be 'outgoing', 'incoming', or 'both'"} + + async def test_kg_add(self, monkeypatch): + kg = MagicMock() + kg.add_triple.return_value = "triple-1" + tool = MempalaceKGAddTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + result = await tool._run_async_impl( + tool_context=_ctx(), + args={"subject": "Alice", "predicate": "works_on", "object": "TRPC"}, + ) + + assert result["success"] is True + assert result["triple_id"] == "triple-1" + + async def test_kg_add_passes_optional_fields(self, monkeypatch): + kg = MagicMock() + kg.add_triple.return_value = "triple-2" + tool = MempalaceKGAddTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + await tool._run_async_impl( + tool_context=_ctx(), + args={ + "subject": "Alice", + "predicate": "works_on", + "object": "TRPC", + "valid_from": "2026-01-01", + "valid_to": "2026-12-31", + "confidence": "0.7", + "source_file": "source.md", + "source_drawer_id": "drawer-1", + }, + ) + + kg.add_triple.assert_called_once_with( + "Alice", + "works_on", + "TRPC", + valid_from="2026-01-01", + valid_to="2026-12-31", + confidence=0.7, + source_file="source.md", + source_drawer_id="drawer-1", + ) + + async def test_kg_invalidate(self, monkeypatch): + kg = MagicMock() + tool = MempalaceKGInvalidateTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + result = await tool._run_async_impl( + tool_context=_ctx(), + args={"subject": "Alice", "predicate": "works_on", "object": "OldProject", "ended": "2026-05-07"}, + ) + + assert result["success"] is True + kg.invalidate.assert_called_once_with("Alice", "works_on", "OldProject", ended="2026-05-07") + + async def test_kg_invalidate_defaults_to_today(self, monkeypatch): + kg = MagicMock() + tool = MempalaceKGInvalidateTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + real_date = mempalace_tool.date + + class FakeDate: + @classmethod + def today(cls): + return real_date(2026, 5, 9) + + monkeypatch.setattr(mempalace_tool, "date", FakeDate) + + result = await tool._run_async_impl( + tool_context=_ctx(), + args={"subject": "Alice", "predicate": "works_on", "object": "OldProject"}, + ) + + assert result["ended"] == "2026-05-09" + kg.invalidate.assert_called_once_with("Alice", "works_on", "OldProject", ended="2026-05-09") + + async def test_kg_timeline(self, monkeypatch): + kg = MagicMock() + kg.timeline.return_value = [{"subject": "Alice"}] + tool = MempalaceKGTimelineTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + result = await tool._run_async_impl(tool_context=_ctx(), args={"entity": "Alice"}) + + assert result["count"] == 1 + kg.timeline.assert_called_once_with("Alice") + + async def test_kg_timeline_defaults_to_all(self, monkeypatch): + kg = MagicMock() + kg.timeline.return_value = [] + tool = MempalaceKGTimelineTool() + monkeypatch.setattr(tool, "_get_knowledge_graph", lambda: kg) + + result = await tool._run_async_impl(tool_context=_ctx(), args={}) + + assert result == {"entity": "all", "timeline": [], "count": 0} + kg.timeline.assert_called_once_with(None) diff --git a/tests/tools/test_preload_memory_tool.py b/tests/tools/test_preload_memory_tool.py new file mode 100644 index 000000000..fa8473722 --- /dev/null +++ b/tests/tools/test_preload_memory_tool.py @@ -0,0 +1,152 @@ +# 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. + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._preload_memory_tool import PreloadMemoryTool, preload_memory_tool +from trpc_agent_sdk.types import Content, MemoryEntry, Part + + +class TestPreloadMemoryToolInit: + + def test_init(self): + tool = PreloadMemoryTool() + assert tool.name == "preload_memory" + assert tool.description == "preload_memory" + + +class TestPreloadMemoryToolRunAsync: + + @pytest.mark.asyncio + async def test_run_returns_empty_dict(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + result = await tool._run_async_impl(tool_context=ctx, args={}) + assert result == {} + + +class TestPreloadMemoryToolProcessRequest: + + def _has_system_instruction(self, llm_request): + return (llm_request.config is not None + and llm_request.config.system_instruction is not None + and len(str(llm_request.config.system_instruction)) > 0) + + @pytest.mark.asyncio + async def test_no_user_content(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = None + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not self._has_system_instruction(llm_request) + + @pytest.mark.asyncio + async def test_user_content_no_parts(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = Content(parts=[]) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not self._has_system_instruction(llm_request) + + @pytest.mark.asyncio + async def test_user_content_no_text(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + part = MagicMock(spec=Part) + part.text = None + ctx.user_content = Content(parts=[part]) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not self._has_system_instruction(llm_request) + + @pytest.mark.asyncio + async def test_no_memories_found(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = Content(parts=[Part.from_text(text="hello")]) + mock_response = MagicMock() + mock_response.memories = [] + ctx.search_memory = AsyncMock(return_value=mock_response) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not self._has_system_instruction(llm_request) + + @pytest.mark.asyncio + async def test_memories_found_adds_instructions(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = Content(parts=[Part.from_text(text="hello")]) + + memory = MagicMock(spec=MemoryEntry) + memory.timestamp = "2026-01-01" + memory.author = "user" + memory.content = Content(parts=[Part.from_text(text="past memory")]) + + mock_response = MagicMock() + mock_response.memories = [memory] + ctx.search_memory = AsyncMock(return_value=mock_response) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert self._has_system_instruction(llm_request) + assert "PAST_CONVERSATIONS" in str(llm_request.config.system_instruction) + + @pytest.mark.asyncio + async def test_memory_without_timestamp(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = Content(parts=[Part.from_text(text="hello")]) + + memory = MagicMock(spec=MemoryEntry) + memory.timestamp = None + memory.author = None + memory.content = Content(parts=[Part.from_text(text="mem text")]) + + mock_response = MagicMock() + mock_response.memories = [memory] + ctx.search_memory = AsyncMock(return_value=mock_response) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert self._has_system_instruction(llm_request) + assert "mem text" in str(llm_request.config.system_instruction) + + @pytest.mark.asyncio + async def test_memory_with_empty_parts(self): + tool = PreloadMemoryTool() + ctx = MagicMock(spec=InvocationContext) + ctx.user_content = Content(parts=[Part.from_text(text="hello")]) + + memory = MagicMock(spec=MemoryEntry) + memory.timestamp = None + memory.author = None + memory.content = Content(parts=[]) + + mock_response = MagicMock() + mock_response.memories = [memory] + ctx.search_memory = AsyncMock(return_value=mock_response) + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + assert not self._has_system_instruction(llm_request) + + +class TestPreloadMemoryToolSingleton: + + def test_module_level_instance(self): + assert isinstance(preload_memory_tool, PreloadMemoryTool) diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py new file mode 100644 index 000000000..63df30886 --- /dev/null +++ b/tests/tools/test_registry.py @@ -0,0 +1,184 @@ +# 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. + +from __future__ import annotations + +from typing import Any, Optional +from unittest.mock import MagicMock, patch + +import pytest +from typing_extensions import override + +from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.tools._function_tool import FunctionTool +from trpc_agent_sdk.tools._registry import ( + ToolRegistry, + ToolSetRegistry, + _ToolManager, + _ToolSetManager, + get_tool, + get_tool_set, + register_tool, + register_tool_set, +) + + +class DummyTool(BaseTool): + + def __init__(self, name="dummy", description="dummy tool"): + super().__init__(name=name, description=description) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return {} + + +class TestToolManager: + + def setup_method(self): + self.manager = _ToolManager() + + def test_add_tool(self): + tool = DummyTool(name="tool1") + self.manager.add(tool) + assert self.manager.get_tool("tool1") is tool + + def test_add_duplicate_raises(self): + tool = DummyTool(name="tool_dup") + self.manager.add(tool) + with pytest.raises(TypeError, match="already exists"): + self.manager.add(DummyTool(name="tool_dup")) + + def test_get_tool_not_found(self): + assert self.manager.get_tool("nonexistent") is None + + def test_get_tool_none_returns_all(self): + t1 = DummyTool(name="a_tool") + t2 = DummyTool(name="b_tool") + self.manager.add(t1) + self.manager.add(t2) + result = self.manager.get_tool(None) + assert isinstance(result, list) + assert len(result) == 2 + + +class TestToolRegistry: + + def test_singleton_behavior(self): + r1 = ToolRegistry() + r2 = ToolRegistry() + assert r1 is r2 + + def test_add_and_get(self): + registry = ToolRegistry() + tool = DummyTool(name=f"reg_tool_{id(self)}") + try: + registry.add(tool) + assert registry.get(tool.name) is tool + finally: + # Cleanup + registry._tool_registry._instance_map.pop(tool.name, None) + + def test_get_nonexistent(self): + registry = ToolRegistry() + assert registry.get("nonexistent_tool_xyz") is None + + +class TestRegisterToolDecorator: + + def test_register_callable(self): + unique_name = f"registered_func_{id(self)}" + + def my_func(x: str) -> str: + """My function.""" + return x + + my_func.__name__ = unique_name + + registry = ToolRegistry() + try: + decorator = register_tool() + result = decorator(my_func) + assert isinstance(result, FunctionTool) + found = registry.get(unique_name) + assert found is not None + finally: + registry._tool_registry._instance_map.pop(unique_name, None) + + def test_register_invalid_type_raises(self): + with pytest.raises(TypeError, match="Can only register"): + register_tool()(42) + + +class TestGetTool: + + def test_get_tool_returns_none_for_missing(self): + result = get_tool("absolutely_nonexistent_tool") + assert result is None + + +class TestToolSetManager: + + def setup_method(self): + self.manager = _ToolSetManager() + + def test_add_toolset(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = "ts1" + self.manager.add(mock_ts) + assert self.manager.get_tool_set("ts1") is mock_ts + + def test_add_duplicate_raises(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = "ts_dup" + self.manager.add(mock_ts) + with pytest.raises(TypeError, match="already exists"): + self.manager.add(mock_ts) + + def test_get_tool_set_none_returns_all(self): + ts1 = MagicMock(spec=BaseToolSet) + ts1.name = "ts_a" + ts2 = MagicMock(spec=BaseToolSet) + ts2.name = "ts_b" + self.manager.add(ts1) + self.manager.add(ts2) + result = self.manager.get_tool_set(None) + assert isinstance(result, list) + assert len(result) == 2 + + def test_get_tool_set_not_found(self): + assert self.manager.get_tool_set("nonexistent") is None + + +class TestToolSetRegistry: + + def test_singleton_behavior(self): + r1 = ToolSetRegistry() + r2 = ToolSetRegistry() + assert r1 is r2 + + def test_add_and_get(self): + registry = ToolSetRegistry() + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = f"toolset_{id(self)}" + try: + registry.add(mock_ts) + assert registry.get(mock_ts.name) is mock_ts + finally: + registry._tool_set_manager._instance_map.pop(mock_ts.name, None) + + def test_get_nonexistent(self): + registry = ToolSetRegistry() + assert registry.get("nonexistent_toolset_xyz") is None + + +class TestGetToolSet: + + def test_returns_none_for_missing(self): + result = get_tool_set("no_such_toolset") + assert result is None diff --git a/tests/tools/test_set_model_response_tool.py b/tests/tools/test_set_model_response_tool.py new file mode 100644 index 000000000..af594b0e6 --- /dev/null +++ b/tests/tools/test_set_model_response_tool.py @@ -0,0 +1,118 @@ +# 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. + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from pydantic import BaseModel + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._set_model_response_tool import SetModelResponseTool +from trpc_agent_sdk.types import FunctionDeclaration + + +class OutputSchema(BaseModel): + answer: str + score: float + + +class EmptySchema(BaseModel): + pass + + +class ComplexSchema(BaseModel): + name: str + tags: list[str] + count: int = 0 + + +class TestSetModelResponseToolInit: + + def test_init(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + assert tool.name == "set_model_response" + assert "final response" in tool.description.lower() or "final structured" in tool.description.lower() + assert tool.output_schema is OutputSchema + + def test_init_creates_local_func(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + assert callable(tool.func) + assert tool.func() == "Response set successfully." + + def test_init_with_empty_schema(self): + tool = SetModelResponseTool(output_schema=EmptySchema) + assert tool.name == "set_model_response" + + def test_each_instance_gets_own_func(self): + tool1 = SetModelResponseTool(output_schema=OutputSchema) + tool2 = SetModelResponseTool(output_schema=ComplexSchema) + assert tool1.func is not tool2.func + + +class TestSetModelResponseToolGetDeclaration: + + def test_get_declaration(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + decl = tool._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == "set_model_response" + + def test_declaration_has_schema_fields(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + decl = tool._get_declaration() + assert decl.parameters is not None + assert decl.parameters.properties is not None + assert "answer" in decl.parameters.properties + assert "score" in decl.parameters.properties + + +class TestSetModelResponseToolRunAsync: + + @pytest.mark.asyncio + async def test_run_validates_and_returns(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + ctx = MagicMock(spec=InvocationContext) + + result = await tool._run_async_impl( + args={"answer": "hello", "score": 0.95}, + tool_context=ctx, + ) + assert result == {"answer": "hello", "score": 0.95} + + @pytest.mark.asyncio + async def test_run_with_complex_schema(self): + tool = SetModelResponseTool(output_schema=ComplexSchema) + ctx = MagicMock(spec=InvocationContext) + + result = await tool._run_async_impl( + args={"name": "test", "tags": ["a", "b"], "count": 5}, + tool_context=ctx, + ) + assert result == {"name": "test", "tags": ["a", "b"], "count": 5} + + @pytest.mark.asyncio + async def test_run_with_default_values(self): + tool = SetModelResponseTool(output_schema=ComplexSchema) + ctx = MagicMock(spec=InvocationContext) + + result = await tool._run_async_impl( + args={"name": "test", "tags": []}, + tool_context=ctx, + ) + assert result["count"] == 0 + + @pytest.mark.asyncio + async def test_run_with_invalid_args_raises(self): + tool = SetModelResponseTool(output_schema=OutputSchema) + ctx = MagicMock(spec=InvocationContext) + + with pytest.raises(Exception): + await tool._run_async_impl( + args={"answer": "hello"}, # Missing 'score' + tool_context=ctx, + ) diff --git a/tests/tools/test_streaming_function_tool.py b/tests/tools/test_streaming_function_tool.py new file mode 100644 index 000000000..602d02602 --- /dev/null +++ b/tests/tools/test_streaming_function_tool.py @@ -0,0 +1,122 @@ +# 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. + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.tools._function_tool import FunctionTool +from trpc_agent_sdk.tools._streaming_function_tool import StreamingFunctionTool + + +def sync_write(path: str, content: str) -> dict: + """Write content to a file.""" + return {"success": True} + + +async def async_write(path: str, content: str) -> dict: + """Async write.""" + return {"success": True} + + +class TestStreamingFunctionToolInit: + + def test_from_sync_function(self): + tool = StreamingFunctionTool(sync_write) + assert tool.name == "sync_write" + assert tool.is_streaming is True + assert tool.func is sync_write + + def test_from_async_function(self): + tool = StreamingFunctionTool(async_write) + assert tool.name == "async_write" + assert tool.is_streaming is True + + def test_from_function_tool(self): + ft = FunctionTool(sync_write) + tool = StreamingFunctionTool(ft) + assert tool.name == "sync_write" + assert tool.is_streaming is True + assert tool.func is sync_write + + def test_from_function_tool_copies_filters(self): + mock_filter = MagicMock(spec=BaseFilter) + ft = FunctionTool(sync_write) + ft._filters = [mock_filter] + tool = StreamingFunctionTool(ft) + assert mock_filter in tool.filters + + def test_from_function_tool_explicit_filters_override(self): + mock_filter = MagicMock(spec=BaseFilter) + ft = FunctionTool(sync_write) + ft._filters = [MagicMock(spec=BaseFilter)] + tool = StreamingFunctionTool(ft, filters=[mock_filter]) + assert mock_filter in tool.filters + + def test_with_invalid_filters_name_raises(self): + with pytest.raises(ValueError, match="not found"): + StreamingFunctionTool(sync_write, filters_name=["nonexistent"]) + + +class TestStreamingFunctionToolIsStreaming: + + def test_is_streaming_always_true(self): + tool = StreamingFunctionTool(sync_write) + assert tool.is_streaming is True + + def test_base_function_tool_is_not_streaming(self): + ft = FunctionTool(sync_write) + assert ft.is_streaming is False + + def test_streaming_overrides_base(self): + ft = FunctionTool(sync_write) + st = StreamingFunctionTool(ft) + assert ft.is_streaming is False + assert st.is_streaming is True + + +class TestStreamingFunctionToolExecution: + + @pytest.mark.asyncio + async def test_run_sync_function(self): + tool = StreamingFunctionTool(sync_write) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + ctx.agent.parallel_tool_calls = False + + result = await tool._run_async_impl( + tool_context=ctx, + args={"path": "/tmp/test", "content": "hello"}, + ) + assert result == {"success": True} + + @pytest.mark.asyncio + async def test_run_async_function(self): + tool = StreamingFunctionTool(async_write) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + + result = await tool._run_async_impl( + tool_context=ctx, + args={"path": "/tmp/test", "content": "hello"}, + ) + assert result == {"success": True} + + def test_get_declaration(self): + tool = StreamingFunctionTool(sync_write) + decl = tool._get_declaration() + assert decl is not None + assert decl.name == "sync_write" + + def test_from_function_tool_no_extra_filters(self): + ft = FunctionTool(sync_write) + tool = StreamingFunctionTool(ft) + assert tool.filters is not None diff --git a/tests/tools/test_streaming_progress_tool.py b/tests/tools/test_streaming_progress_tool.py new file mode 100644 index 000000000..3b961dfb8 --- /dev/null +++ b/tests/tools/test_streaming_progress_tool.py @@ -0,0 +1,138 @@ +# 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. + +from __future__ import annotations + +from typing import AsyncIterator +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._function_tool import FunctionTool +from trpc_agent_sdk.tools._streaming_progress_tool import StreamingProgressTool + + +async def streaming_func(query: str) -> AsyncIterator[dict]: + """Stream a few progress updates.""" + yield {"status": "started", "query": query} + yield {"status": "step", "step": 1} + yield {"status": "step", "step": 2} + yield {"status": "done", "query": query, "steps": 2} + + +async def streaming_func_no_yield() -> AsyncIterator[dict]: + """Generator that never yields.""" + if False: # pragma: no cover + yield {} + + +async def streaming_func_str() -> AsyncIterator[str]: + """Stream a few string progress updates.""" + yield "starting" + yield "halfway" + yield "completed" + + +def regular_sync_func(x: int) -> int: + """Not a generator.""" + return x + + +async def regular_async_func(x: int) -> int: + """Not a generator.""" + return x + + +class TestStreamingProgressToolInit: + + def test_init_with_async_generator(self): + tool = StreamingProgressTool(streaming_func) + assert tool.name == "streaming_func" + assert tool.is_progress_streaming is True + assert tool.func is streaming_func + + def test_init_rejects_sync_function(self): + with pytest.raises(TypeError, match="async def.*generator"): + StreamingProgressTool(regular_sync_func) # type: ignore[arg-type] + + def test_init_rejects_plain_async_function(self): + with pytest.raises(TypeError, match="async def.*generator"): + StreamingProgressTool(regular_async_func) # type: ignore[arg-type] + + def test_is_progress_streaming_property(self): + tool = StreamingProgressTool(streaming_func) + assert tool.is_progress_streaming is True + + def test_base_function_tool_is_not_progress_streaming(self): + # Plain FunctionTool must not silently inherit the streaming flag. + ft = FunctionTool(regular_sync_func) + assert getattr(ft, "is_progress_streaming", False) is False + + +class TestStreamingProgressToolExecution: + + @pytest.mark.asyncio + async def test_run_streaming_yields_all_values(self): + tool = StreamingProgressTool(streaming_func) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + + out = [] + async for value in tool.run_streaming(tool_context=ctx, args={"query": "hi"}): + out.append(value) + assert out == [ + {"status": "started", "query": "hi"}, + {"status": "step", "step": 1}, + {"status": "step", "step": 2}, + {"status": "done", "query": "hi", "steps": 2}, + ] + + @pytest.mark.asyncio + async def test_run_streaming_with_string_payloads(self): + tool = StreamingProgressTool(streaming_func_str) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + + out = [] + async for value in tool.run_streaming(tool_context=ctx, args={}): + out.append(value) + assert out == ["starting", "halfway", "completed"] + + @pytest.mark.asyncio + async def test_run_streaming_missing_mandatory_arg(self): + tool = StreamingProgressTool(streaming_func) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + + out = [] + async for value in tool.run_streaming(tool_context=ctx, args={}): + out.append(value) + # Exactly one error payload, no exception bubbled up. + assert len(out) == 1 + assert "error" in out[0] + assert "missing" in out[0]["error"].lower() + + @pytest.mark.asyncio + async def test_run_async_impl_refuses_direct_invocation(self): + # Single-responsibility: streaming tools must NOT be drainable via + # the synchronous tool path. The only entry point is run_streaming(), + # which ToolsProcessor.execute_tools_async calls. + tool = StreamingProgressTool(streaming_func) + ctx = MagicMock(spec=InvocationContext) + ctx.agent = MagicMock() + + with pytest.raises(RuntimeError, match="does not support direct"): + await tool._run_async_impl(tool_context=ctx, args={"query": "hi"}) + + +class TestStreamingProgressToolDeclaration: + + def test_get_declaration_includes_function_name(self): + tool = StreamingProgressTool(streaming_func) + decl = tool._get_declaration() + assert decl is not None + assert decl.name == "streaming_func" diff --git a/tests/tools/test_todo_tool.py b/tests/tools/test_todo_tool.py new file mode 100644 index 000000000..5b30de8ca --- /dev/null +++ b/tests/tools/test_todo_tool.py @@ -0,0 +1,211 @@ +# 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. +"""Tests for :mod:`trpc_agent_sdk.tools._todo_tool`.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from trpc_agent_sdk.agents._base_agent import BaseAgent +from trpc_agent_sdk.context import InvocationContext, create_agent_context +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._todo_tool import ( + DEFAULT_STATE_KEY_PREFIX, + TodoItem, + TodoStatus, + TodoWriteTool, + get_todos, + render_todos, + state_key, + validate_todos, +) +from trpc_agent_sdk.types import Content, EventActions, Part, State + + +class _StubAgent(BaseAgent): + async def _run_async_impl(self, ctx): + yield + + +def _sample_todos(**statuses: str) -> list[dict]: + defaults = { + "step1": ("Run step 1", "Running step 1", TodoStatus.IN_PROGRESS), + "step2": ("Run step 2", "Running step 2", TodoStatus.PENDING), + } + return [{ + "content": defaults[name][0], + "activeForm": defaults[name][1], + "status": statuses.get(name, defaults[name][2].value), + } for name in ("step1", "step2")] + + +@pytest.fixture +def session_bundle(): + service = InMemorySessionService() + session = asyncio.run( + service.create_session(app_name="test", user_id="u1", session_id="s1") + ) + agent = _StubAgent(name="todo_planner") + ctx = InvocationContext( + session_service=service, + invocation_id="inv-1", + agent=agent, + agent_context=create_agent_context(), + session=session, + branch="", + ) + return service, session, agent, ctx + + +class TestValidateTodos: + def test_accepts_valid_list(self): + items = [ + TodoItem(content="A", activeForm="Doing A", status=TodoStatus.IN_PROGRESS), + TodoItem(content="B", activeForm="Doing B", status=TodoStatus.PENDING), + ] + assert validate_todos(items) is None + + def test_rejects_multiple_in_progress(self): + items = [ + TodoItem(content="A", activeForm="Doing A", status=TodoStatus.IN_PROGRESS), + TodoItem(content="B", activeForm="Doing B", status=TodoStatus.IN_PROGRESS), + ] + assert "in_progress" in (validate_todos(items) or "") + + def test_rejects_duplicate_content(self): + items = [ + TodoItem(content="Same", activeForm="Doing same", status=TodoStatus.IN_PROGRESS), + TodoItem(content="Same", activeForm="Still same", status=TodoStatus.PENDING), + ] + assert "duplicates" in (validate_todos(items) or "") + + +class TestStateKey: + def test_default_prefix_without_branch(self): + assert state_key(DEFAULT_STATE_KEY_PREFIX, "") == "todos" + + def test_appends_branch(self): + assert state_key(DEFAULT_STATE_KEY_PREFIX, "todo_planner") == "todos:todo_planner" + + +class TestProcessRequest: + @pytest.mark.asyncio + async def test_process_request_adds_instructions(self, session_bundle): + _, _, _, ctx = session_bundle + tool = TodoWriteTool() + llm_request = LlmRequest() + + await tool.process_request(tool_context=ctx, llm_request=llm_request) + + assert tool.name in llm_request.tools_dict + assert llm_request.config is not None + assert llm_request.config.system_instruction is not None + assert "todo_write" in str(llm_request.config.system_instruction).lower() + + +class TestTodoWriteTool: + @pytest.mark.asyncio + async def test_writes_and_returns_echo(self, session_bundle): + _, _, agent, ctx = session_bundle + tool = TodoWriteTool(clear_on_all_done=False) + payload = _sample_todos() + + result = await tool._run_async_impl(tool_context=ctx, args={"todos": payload}) + + assert "error" not in result + assert len(result["todos"]) == 2 + assert result["oldTodos"] is None + key = state_key(DEFAULT_STATE_KEY_PREFIX, agent.name) + assert key in ctx.state._delta + + @pytest.mark.asyncio + async def test_reads_previous_list_on_second_call(self, session_bundle): + _, _, agent, ctx = session_bundle + tool = TodoWriteTool(clear_on_all_done=False) + first = await tool._run_async_impl(tool_context=ctx, args={"todos": _sample_todos()}) + assert first["oldTodos"] is None + + second = await tool._run_async_impl( + tool_context=ctx, + args={"todos": _sample_todos(step1="completed", step2="in_progress")}, + ) + assert len(second["oldTodos"]) == 2 + assert second["todos"][0]["status"] == "completed" + assert second["todos"][1]["status"] == "in_progress" + assert state_key(DEFAULT_STATE_KEY_PREFIX, agent.name) in ctx.session.state + + @pytest.mark.asyncio + async def test_rejects_missing_todos_field(self, session_bundle): + _, _, _, ctx = session_bundle + result = await TodoWriteTool()._run_async_impl(tool_context=ctx, args={}) + assert "error" in result + + @pytest.mark.asyncio + async def test_clear_on_all_done(self, session_bundle): + _, _, _, ctx = session_bundle + tool = TodoWriteTool(clear_on_all_done=True) + result = await tool._run_async_impl( + tool_context=ctx, + args={"todos": _sample_todos(step1="completed", step2="completed")}, + ) + assert result["todos"] == [] + + +class TestPersistence: + @pytest.mark.asyncio + async def test_todos_prefix_survives_append_event_and_get_session(self, session_bundle): + service, session, agent, ctx = session_bundle + tool = TodoWriteTool(clear_on_all_done=False) + await tool._run_async_impl(tool_context=ctx, args={"todos": _sample_todos()}) + + event = Event( + invocation_id="inv-1", + author=agent.name, + content=Content(parts=[Part.from_text(text="tool result")]), + actions=EventActions(state_delta=dict(ctx.event_actions.state_delta)), + ) + await service.append_event(session, event) + + stored = await service.get_session(app_name="test", user_id="u1", session_id="s1") + todos = get_todos(stored, branch=agent.name) + assert len(todos) == 2 + assert todos[0].status == TodoStatus.IN_PROGRESS + + @pytest.mark.asyncio + async def test_temp_prefix_is_not_persisted(self, session_bundle): + service, session, agent, ctx = session_bundle + tool = TodoWriteTool(state_key_prefix="temp:todos", clear_on_all_done=False) + await tool._run_async_impl(tool_context=ctx, args={"todos": _sample_todos()}) + + event = Event( + invocation_id="inv-1", + author=agent.name, + content=Content(parts=[Part.from_text(text="tool result")]), + actions=EventActions(state_delta=dict(ctx.event_actions.state_delta)), + ) + await service.append_event(session, event) + + stored = await service.get_session(app_name="test", user_id="u1", session_id="s1") + key = state_key("temp:todos", agent.name) + assert key not in (stored.state or {}) + assert get_todos(stored, branch=agent.name, prefix="temp:todos") == [] + + +class TestRenderTodos: + def test_renders_checklist(self): + items = [ + TodoItem(content="Done task", activeForm="Doing done", status=TodoStatus.COMPLETED), + TodoItem(content="Active task", activeForm="Doing active", status=TodoStatus.IN_PROGRESS), + TodoItem(content="Pending task", activeForm="Doing pending", status=TodoStatus.PENDING), + ] + rendered = render_todos(items) + assert "✅ Done task" in rendered + assert "🔄 Doing active" in rendered + assert "⬜ Pending task" in rendered diff --git a/tests/tools/test_tool_adapter.py b/tests/tools/test_tool_adapter.py new file mode 100644 index 000000000..58ddffbfe --- /dev/null +++ b/tests/tools/test_tool_adapter.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. + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from typing_extensions import override + +from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._base_tool import BaseTool +from trpc_agent_sdk.tools._function_tool import FunctionTool +from trpc_agent_sdk.tools._tool_adapter import ( + convert_toolunion_to_tool_list, + create_tool, + create_toolset, +) + + +class DummyTool(BaseTool): + + def __init__(self, name="dummy", description="dummy"): + super().__init__(name=name, description=description) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return {} + + +def sample_callable(x: str) -> str: + """Sample.""" + return x + + +class TestCreateTool: + + def test_create_from_callable(self): + tool = create_tool(sample_callable) + assert isinstance(tool, FunctionTool) + assert tool.name == "sample_callable" + + def test_create_from_base_tool(self): + t = DummyTool(name="my_tool") + result = create_tool(t) + assert result is t + + def test_create_from_string_found(self): + with patch("trpc_agent_sdk.tools._tool_adapter.get_tool") as mock_get: + mock_tool = DummyTool(name="from_reg") + mock_get.return_value = mock_tool + result = create_tool("from_reg") + assert result is mock_tool + + def test_create_from_string_not_found_raises(self): + with patch("trpc_agent_sdk.tools._tool_adapter.get_tool", return_value=None): + with pytest.raises(ValueError, match="Cannot find tool"): + create_tool("missing") + + def test_create_from_unsupported_type_raises(self): + with pytest.raises(TypeError, match="Unsupported tool type"): + create_tool(12345) + + def test_create_from_list(self): + t = DummyTool(name="t1") + result = create_tool([sample_callable, t]) + assert isinstance(result, list) + assert len(result) == 2 + assert isinstance(result[0], FunctionTool) + assert result[1] is t + + def test_create_single_returns_single(self): + result = create_tool(sample_callable) + assert isinstance(result, BaseTool) + assert not isinstance(result, list) + + def test_create_with_filters_name(self): + with patch.object(FunctionTool, "add_filters") as mock_add: + create_tool(sample_callable, filters_name=["f1"]) + mock_add.assert_called_once_with(["f1"], True) + + def test_create_with_need_cache(self): + with patch("trpc_agent_sdk.tools._tool_adapter.ToolRegistry") as MockReg: + mock_registry = MagicMock() + MockReg.return_value = mock_registry + create_tool(sample_callable, need_cache=True) + mock_registry.add.assert_called_once() + + +class TestCreateToolset: + + def test_create_from_base_toolset(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = "ts" + result = create_toolset(mock_ts) + assert result is mock_ts + + def test_create_from_string_found(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = "found_ts" + with patch("trpc_agent_sdk.tools._tool_adapter.get_tool_set", return_value=mock_ts): + result = create_toolset("found_ts") + assert result is mock_ts + + def test_create_from_string_not_found_raises(self): + with patch("trpc_agent_sdk.tools._tool_adapter.get_tool_set", return_value=None): + with pytest.raises(ValueError, match="Cannot find toolset"): + create_toolset("missing_ts") + + def test_create_from_callable_wraps_in_default_toolset(self): + result = create_toolset(sample_callable) + assert isinstance(result, BaseToolSet) + + def test_create_from_list(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = "ts1" + result = create_toolset([mock_ts, sample_callable]) + assert isinstance(result, list) + assert len(result) == 2 + + def test_create_from_list_with_need_cache(self): + mock_ts = MagicMock(spec=BaseToolSet) + mock_ts.name = f"ts_cache_{id(self)}" + with patch("trpc_agent_sdk.tools._tool_adapter.ToolSetRegistry") as MockReg: + mock_registry = MagicMock() + MockReg.return_value = mock_registry + create_toolset([mock_ts], need_cache=True) + mock_registry.add.assert_called_once() + + +class TestConvertToolunionToToolList: + + @pytest.mark.asyncio + async def test_with_base_tools(self): + t1 = DummyTool(name="t1") + t2 = DummyTool(name="t2") + ctx = MagicMock(spec=InvocationContext) + + result = await convert_toolunion_to_tool_list([t1, t2], ctx) + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_with_toolset(self): + mock_ts = AsyncMock(spec=BaseToolSet) + t1 = DummyTool(name="ts_tool") + mock_ts.get_tools = AsyncMock(return_value=[t1]) + ctx = MagicMock(spec=InvocationContext) + + result = await convert_toolunion_to_tool_list([mock_ts], ctx) + assert len(result) == 1 + assert result[0] is t1 + + @pytest.mark.asyncio + async def test_mixed_tools_and_toolsets(self): + t1 = DummyTool(name="direct") + mock_ts = AsyncMock(spec=BaseToolSet) + t2 = DummyTool(name="from_ts") + mock_ts.get_tools = AsyncMock(return_value=[t2]) + ctx = MagicMock(spec=InvocationContext) + + result = await convert_toolunion_to_tool_list([t1, mock_ts], ctx) + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_unsupported_type_raises(self): + ctx = MagicMock(spec=InvocationContext) + with pytest.raises(TypeError, match="Unsupported tool type"): + await convert_toolunion_to_tool_list(["not_a_tool"], ctx) + + @pytest.mark.asyncio + async def test_empty_list(self): + ctx = MagicMock(spec=InvocationContext) + result = await convert_toolunion_to_tool_list([], ctx) + assert result == [] diff --git a/tests/tools/test_transfer_to_agent_tool.py b/tests/tools/test_transfer_to_agent_tool.py new file mode 100644 index 000000000..0e9c28a5f --- /dev/null +++ b/tests/tools/test_transfer_to_agent_tool.py @@ -0,0 +1,39 @@ +# 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. + +from unittest.mock import MagicMock + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools._transfer_to_agent_tool import transfer_to_agent + + +class TestTransferToAgent: + + def test_sets_transfer_action(self): + mock_ctx = MagicMock(spec=InvocationContext) + mock_ctx.actions = MagicMock() + + transfer_to_agent("target_agent", mock_ctx) + + assert mock_ctx.actions.transfer_to_agent == "target_agent" + + def test_returns_dict_with_agent_name(self): + mock_ctx = MagicMock(spec=InvocationContext) + mock_ctx.actions = MagicMock() + + result = transfer_to_agent("my_agent", mock_ctx) + + assert result == {"transferred_to": "my_agent"} + + def test_with_different_agent_names(self): + for name in ["agent_a", "agent_b", "helper", ""]: + mock_ctx = MagicMock(spec=InvocationContext) + mock_ctx.actions = MagicMock() + + result = transfer_to_agent(name, mock_ctx) + + assert mock_ctx.actions.transfer_to_agent == name + assert result == {"transferred_to": name} diff --git a/tests/tools/test_websearch_tool.py b/tests/tools/test_websearch_tool.py new file mode 100644 index 000000000..518fa2a2e --- /dev/null +++ b/tests/tools/test_websearch_tool.py @@ -0,0 +1,1191 @@ +# 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. +"""Tests for :mod:`trpc_agent_sdk.tools._websearch_tool`. + +Covers the full BaseTool surface area: +- ``SearchHit`` / ``WebSearchResult`` schemas +- Constructor validation and provider dispatch +- ``_get_declaration`` parameter shape +- Input validation (missing query, short query, mutually exclusive + ``allowed_domains`` / ``blocked_domains``) +- DuckDuckGo Instant Answer path (summary aggregation, related topics, + fallback result, post-hoc domain filtering) +- Google CSE path (items, pagemap metatag enrichment, spell-corrected + effective query, server-side domain filter parameters, API error, + missing credentials) +- 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 + +import json +from typing import Any +from typing import Dict +from unittest.mock import MagicMock + +import httpx +import pytest + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools._websearch_tool import SearchHit +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 +from trpc_agent_sdk.tools._websearch_tool import _dedup_key +from trpc_agent_sdk.tools._websearch_tool import _extract_domain_from_url +from trpc_agent_sdk.tools._websearch_tool import _extract_desc_from_pagemap +from trpc_agent_sdk.tools._websearch_tool import _extract_title_from_ddg_topic +from trpc_agent_sdk.tools._websearch_tool import _is_blocked +from trpc_agent_sdk.tools._websearch_tool import _truncate +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Type + +# Tool name is hard-coded inside WebSearchTool.__init__; tests compare by +# literal to catch accidental rename regressions. +_TOOL_NAME = "websearch" + + +def _make_mock_client(responses: Dict[str, Dict[str, Any]], *, status: int = 200) -> httpx.AsyncClient: + """Build an ``httpx.AsyncClient`` backed by ``MockTransport``. + + ``responses`` maps request URL path (e.g. ``"/"``) to a JSON body. + All non-matching paths return 404 so test misses surface loudly. + The returned client captures the last request for assertions via + ``client._last_request``. + """ + + captured = {"last_request": None, "all_requests": []} + + def handler(request: httpx.Request) -> httpx.Response: + captured["last_request"] = request + captured["all_requests"].append(request) + body = responses.get(request.url.path) + if body is None: + return httpx.Response(404, json={"error": "no mock for path"}) + return httpx.Response(status, json=body) + + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) + # Stash captures on the client so tests can introspect them. + client._captured = captured # type: ignore[attr-defined] + return client + + +def _tool_ctx() -> InvocationContext: + """Return a minimal fake ``InvocationContext`` — our tool does not touch it.""" + return MagicMock(spec=InvocationContext) + + +class TestSearchHitSchema: + + def test_defaults(self): + hit = SearchHit() + assert hit.title == "" + assert hit.url == "" + assert hit.snippet == "" + + def test_roundtrip(self): + hit = SearchHit(title="T", url="https://x", snippet="S") + restored = SearchHit.model_validate(hit.model_dump()) + assert restored == hit + + +class TestWebSearchResultSchema: + + def test_defaults(self): + r = WebSearchResult(query="q", provider="duckduckgo") + assert r.results == [] + assert r.summary == "" + + def test_model_dump_is_json_serialisable(self): + r = WebSearchResult(query="q", provider="google", results=[SearchHit(title="t", url="u")]) + as_json = json.dumps(r.model_dump(), ensure_ascii=False) + parsed = json.loads(as_json) + assert parsed["provider"] == "google" + assert parsed["results"][0]["title"] == "t" + + +class TestWebSearchToolInit: + + def test_default_is_duckduckgo(self): + t = WebSearchTool() + 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_google_without_creds_warns_not_raises(self, caplog): + # Capture warnings from the tool's logger. + caplog.set_level("WARNING") + t = WebSearchTool(provider="google", api_key="", engine_id="") + assert t._provider == "google" + + def test_results_num_clamped(self): + from trpc_agent_sdk.tools._websearch_tool import _MAX_COUNT + # Above the cap → clamped to _MAX_COUNT. + assert WebSearchTool(results_num=_MAX_COUNT + 50)._results_num == _MAX_COUNT + # Below 1 → clamped up to 1. + assert WebSearchTool(results_num=0)._results_num == 1 + + +class TestGetDeclaration: + + def test_declaration_shape(self): + decl = WebSearchTool()._get_declaration() + assert isinstance(decl, FunctionDeclaration) + assert decl.name == _TOOL_NAME + props = decl.parameters.properties + assert set(props.keys()) == { + "query", + "count", + "allowed_domains", + "blocked_domains", + "lang", + } + assert decl.parameters.required == ["query"] + # Arrays use nested schema items. + assert props["allowed_domains"].type == Type.ARRAY + assert props["allowed_domains"].items is not None + + +class TestInputValidation: + + @pytest.mark.asyncio + async def test_empty_query_errors(self): + res = await WebSearchTool()._run_async_impl(tool_context=_tool_ctx(), args={}) + assert "error" in res + assert "INVALID_QUERY" in res["error"] + + @pytest.mark.asyncio + async def test_short_query_errors(self): + res = await WebSearchTool()._run_async_impl(tool_context=_tool_ctx(), args={"query": "a"}) + assert "INVALID_QUERY" in res["error"] + + @pytest.mark.asyncio + async def test_conflicting_domains_errors(self): + res = await WebSearchTool()._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["a.com"], + "blocked_domains": ["b.com"], + }, + ) + assert "INVALID_ARGS" in res["error"] + + @pytest.mark.asyncio + async def test_all_empty_allowed_domains_errors(self): + """Caller passing an allowlist of only empty strings used to silently + drop every result via the fail-closed allowlist branch — we now + surface INVALID_ARGS so the LLM can recover. + """ + res = await WebSearchTool()._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["", " "], + }, + ) + assert "INVALID_ARGS" in res["error"] + assert "allowed_domains" in res["error"] + + @pytest.mark.asyncio + async def test_all_empty_blocked_domains_errors(self): + res = await WebSearchTool()._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "blocked_domains": ["", " "], + }, + ) + assert "INVALID_ARGS" in res["error"] + assert "blocked_domains" in res["error"] + + @pytest.mark.asyncio + async def test_partial_empty_domains_are_silently_trimmed(self): + """Mixed list ``["python.org", ""]`` should still work — empties + are trimmed and the request proceeds with the meaningful entry. + """ + client = _make_mock_client({"/": _MIN_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["python.org", "", " "], + }, + ) + assert "error" not in res + await client.aclose() + + @pytest.mark.asyncio + async def test_non_list_domains_errors(self): + for key in ("allowed_domains", "blocked_domains"): + res = await WebSearchTool()._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + key: "python.org", + }, + ) + assert "INVALID_ARGS" in res["error"] + assert key in res["error"] + + @pytest.mark.asyncio + async def test_non_string_domain_items_error(self): + """Non-string items in a domain list must produce a structured + ``INVALID_ARGS`` instead of crashing the tool with ``AttributeError`` + from the internal ``.strip()`` call. + """ + for key in ("allowed_domains", "blocked_domains"): + for bad_value in ([123, "python.org"], [{"d": "x"}], [None]): + res = await WebSearchTool()._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + key: bad_value, + }, + ) + assert "INVALID_ARGS" in res["error"], (key, bad_value, res) + assert key in res["error"], (key, bad_value, res) + + @pytest.mark.asyncio + async def test_count_clamped(self): + """Invalid ``count`` strings must not crash the tool.""" + + client = _make_mock_client({"/": _MIN_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "count": "not-a-number" + }, + ) + # Count fallback should succeed (not error). + assert "error" not in res + await client.aclose() + + +_MIN_DDG_RESPONSE: Dict[str, Any] = { + "Answer": "", + "AbstractText": "", + "Definition": "", + "RelatedTopics": [], +} + +_FULL_DDG_RESPONSE: Dict[str, Any] = { + "Answer": + "Python is a programming language.", + "AbstractText": + "Python is an interpreted, high-level language.", + "AbstractSource": + "Wikipedia", + "Definition": + "A general-purpose programming language.", + "DefinitionSource": + "MerriamWebster", + "RelatedTopics": [ + { + "Text": "Python (programming language) - A popular language used everywhere", + "FirstURL": "https://duckduckgo.com/Python", + }, + # Nested group — should get flattened. + { + "Topics": [{ + "Text": "CPython - Reference implementation", + "FirstURL": "https://docs.python.org/cpython", + }], + }, + # Entry with no URL — must be skipped. + { + "Text": "No URL here", + "FirstURL": "", + }, + ], +} + + +class TestDuckDuckGoProvider: + + @pytest.mark.asyncio + async def test_happy_path(self): + client = _make_mock_client({"/": _FULL_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={"query": "python"}, + ) + assert res["provider"] == "duckduckgo" + assert res["query"] == "python" + # Summary pulls in Answer + Abstract(+source) + Definition(+source). + assert "Python is a programming language." in res["summary"] + assert "Wikipedia" in res["summary"] + assert "MerriamWebster" in res["summary"] + # Two valid hits (one top-level, one flattened from nested Topics). + urls = [h["url"] for h in res["results"]] + assert "https://duckduckgo.com/Python" in urls + assert "https://docs.python.org/cpython" in urls + # Title is split on " - ". + titles = [h["title"] for h in res["results"]] + assert any(t.startswith("Python (programming language)") for t in titles) + await client.aclose() + + @pytest.mark.asyncio + async def test_fallback_when_no_topics_but_summary(self): + body = { + "Answer": "42", + "AbstractText": "", + "Definition": "", + "RelatedTopics": [], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "life"}) + assert len(res["results"]) == 1 + assert res["results"][0]["url"].startswith("https://duckduckgo.com/?q=") + await client.aclose() + + @pytest.mark.asyncio + async def test_empty_response_no_hits_no_summary(self): + client = _make_mock_client({"/": _MIN_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "xyz"}) + assert res["results"] == [] + assert res["summary"] == "" + await client.aclose() + + @pytest.mark.asyncio + async def test_client_side_domain_filtering(self): + client = _make_mock_client({"/": _FULL_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + # Block python.org → docs.python.org should disappear; only ddg hit survives. + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "blocked_domains": ["python.org"], + }, + ) + urls = [h["url"] for h in res["results"]] + assert "https://docs.python.org/cpython" not in urls + assert "https://duckduckgo.com/Python" in urls + await client.aclose() + + @pytest.mark.asyncio + async def test_count_limits_hits(self): + client = _make_mock_client({"/": _FULL_DDG_RESPONSE}) + t = WebSearchTool(http_client=client, results_num=1) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + assert len(res["results"]) == 1 + await client.aclose() + + @pytest.mark.asyncio + async def test_results_are_deduplicated_by_url(self): + """DDG often returns the same URL twice (top-level + nested + ``Topics``); the tool must keep only the first occurrence so the + LLM doesn't waste tokens citing the same source twice. + """ + body = { + "Answer": + "", + "AbstractText": + "", + "Definition": + "", + "RelatedTopics": [ + { + "Text": "Python (programming language) - first occurrence", + "FirstURL": "https://docs.python.org/3/", + }, + { + "Topics": [ + # Same URL with trailing slash dropped + www. → must dedupe. + { + "Text": "Python docs duplicate", + "FirstURL": "https://www.docs.python.org/3", + }, + # A genuinely different URL must still come through. + { + "Text": "PEP 8 - style guide", + "FirstURL": "https://peps.python.org/pep-0008/", + }, + ], + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + # Only the first variant of the duplicated URL is kept; second one is dropped. + assert urls == [ + "https://docs.python.org/3/", + "https://peps.python.org/pep-0008/", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_dedup_urls_false_preserves_duplicates(self): + """When the caller opts out of deduplication every provider hit + must come through verbatim — useful for downstream re-rankers + that want to see near-duplicate variants. + """ + body = { + "Answer": + "", + "AbstractText": + "", + "Definition": + "", + "RelatedTopics": [ + { + "Text": "Python (programming language) - first occurrence", + "FirstURL": "https://docs.python.org/3/", + }, + { + "Topics": [ + { + "Text": "Python docs duplicate", + "FirstURL": "https://www.docs.python.org/3", + }, + ], + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, dedup_urls=False, results_num=5) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + # Both variants come through because dedup is off. + assert urls == [ + "https://docs.python.org/3/", + "https://www.docs.python.org/3", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_snippet_and_title_len_respected(self): + """DDG branch must honour tool-level snippet_len/title_len overrides. + + Previously these two knobs only affected the Google branch while DDG + hard-coded 300/100, producing inconsistent output shapes between + providers. + """ + long_text = ("Python (programming language) - " + ("x" * 500)) + body = { + "Answer": "", + "AbstractText": "", + "Definition": "", + "RelatedTopics": [{ + "Text": long_text, + "FirstURL": "https://duckduckgo.com/Python", + }], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, snippet_len=50, title_len=10) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + hit = res["results"][0] + # +3 for the "..." suffix appended by _truncate. + assert len(hit["snippet"]) <= 50 + 3 + assert len(hit["title"]) <= 10 + 3 + await client.aclose() + + @pytest.mark.asyncio + async def test_fallback_snippet_and_title_len_respected(self): + """Fallback synthetic hit must also honour snippet_len/title_len.""" + body = { + "Answer": "Y" * 500, + "AbstractText": "", + "Definition": "", + "RelatedTopics": [], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, snippet_len=40, title_len=8) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "a-very-long-fallback-query"}) + hit = res["results"][0] + assert len(hit["snippet"]) <= 40 + 3 + assert len(hit["title"]) <= 8 + 3 + await client.aclose() + + @pytest.mark.asyncio + async def test_abstract_url_is_extracted_as_first_hit(self): + """The abstract's canonical URL (usually Wikipedia) must be the + primary citation, NOT internal ``duckduckgo.com/c/...`` entries + from ``RelatedTopics``. + + This was the single most visible demo regression: DDG's + ``RelatedTopics`` URLs are all ``https://duckduckgo.com/...`` + category pages, so for any entity-like query the tool used to + return five useless self-links instead of a Wikipedia source. + """ + body = { + "Heading": "Python (programming language)", + "Answer": "", + "AbstractText": "Python is an interpreted language.", + "AbstractSource": "Wikipedia", + "AbstractURL": "https://en.wikipedia.org/wiki/Python_(programming_language)", + "Definition": "", + "RelatedTopics": [ + { + "Text": "Python Category", + "FirstURL": "https://duckduckgo.com/c/Python", + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + assert urls[0] == "https://en.wikipedia.org/wiki/Python_(programming_language)" + assert res["results"][0]["title"] == "Python (programming language)" + # RelatedTopics still come after the canonical abstract URL. + assert "https://duckduckgo.com/c/Python" in urls + await client.aclose() + + @pytest.mark.asyncio + async def test_definition_url_is_extracted_when_distinct(self): + """``DefinitionURL`` (dictionary source) must also produce a hit.""" + body = { + "Heading": "Vector database", + "Answer": "", + "AbstractText": "A vector database stores embeddings.", + "AbstractURL": "https://en.wikipedia.org/wiki/Vector_database", + "Definition": "A database optimised for vector similarity search.", + "DefinitionSource": "Wiktionary", + "DefinitionURL": "https://en.wiktionary.org/wiki/vector_database", + "RelatedTopics": [], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "vector database"}) + urls = [h["url"] for h in res["results"]] + assert "https://en.wikipedia.org/wiki/Vector_database" in urls + assert "https://en.wiktionary.org/wiki/vector_database" in urls + # AbstractURL is the first (higher-priority) entry. + assert urls[0] == "https://en.wikipedia.org/wiki/Vector_database" + await client.aclose() + + @pytest.mark.asyncio + async def test_results_field_is_extracted(self): + """DDG's ``Results`` array (external links for brand queries) must + flow through just like ``RelatedTopics`` but at a higher priority. + """ + body = { + "Heading": "GitHub", + "Answer": "", + "AbstractText": "GitHub is a code hosting platform.", + "AbstractURL": "https://en.wikipedia.org/wiki/GitHub", + "Definition": "", + "Results": [ + { + "Text": "GitHub - Official site", + "FirstURL": "https://github.com", + }, + ], + "RelatedTopics": [ + { + "Text": "Git (software)", + "FirstURL": "https://duckduckgo.com/Git", + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "github"}) + urls = [h["url"] for h in res["results"]] + # Priority order: AbstractURL → Results → RelatedTopics. + assert urls == [ + "https://en.wikipedia.org/wiki/GitHub", + "https://github.com", + "https://duckduckgo.com/Git", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_abstract_url_respects_allowed_domains(self): + """An ``allowed_domains=['wikipedia.org']`` request must now keep + the Wikipedia abstract URL and drop internal DDG pages — this + was broken before because the abstract URL was never extracted. + """ + body = { + "Heading": "Python (programming language)", + "AbstractText": "Python is an interpreted language.", + "AbstractURL": "https://en.wikipedia.org/wiki/Python_(programming_language)", + "RelatedTopics": [ + { + "Text": "Python Category", + "FirstURL": "https://duckduckgo.com/c/Python", + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["wikipedia.org"], + }, + ) + urls = [h["url"] for h in res["results"]] + assert urls == [ + "https://en.wikipedia.org/wiki/Python_(programming_language)", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_abstract_and_related_topics_dedupe_against_each_other(self): + """If ``AbstractURL`` and a ``RelatedTopics`` entry happen to + point at the same page (same normalised key) we must only keep + one — the higher-priority ``AbstractURL`` copy. + """ + body = { + "Heading": + "Python", + "AbstractText": + "Python programming language.", + "AbstractURL": + "https://en.wikipedia.org/wiki/Python_(programming_language)", + "RelatedTopics": [ + { + "Text": "Python programming language", + # Same URL with trailing slash + www. → dedupe key collides. + "FirstURL": "https://www.en.wikipedia.org/wiki/Python_(programming_language)/", + }, + { + "Text": "CPython - Reference implementation", + "FirstURL": "https://docs.python.org/cpython", + }, + ], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + assert urls == [ + "https://en.wikipedia.org/wiki/Python_(programming_language)", + "https://docs.python.org/cpython", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_count_limit_caps_high_priority_sources(self): + """``count=1`` must keep only the highest-priority hit + (AbstractURL) and drop everything else — including Results / + RelatedTopics. + """ + body = { + "Heading": "Python", + "AbstractText": "Python is a language.", + "AbstractURL": "https://en.wikipedia.org/wiki/Python_(programming_language)", + "Results": [{ + "Text": "Python.org", + "FirstURL": "https://python.org", + }], + "RelatedTopics": [{ + "Text": "NumPy", + "FirstURL": "https://duckduckgo.com/NumPy", + }], + } + client = _make_mock_client({"/": body}) + t = WebSearchTool(http_client=client, results_num=5) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "count": 1, + }, + ) + urls = [h["url"] for h in res["results"]] + assert urls == [ + "https://en.wikipedia.org/wiki/Python_(programming_language)", + ] + await client.aclose() + + +_GOOGLE_RESPONSE: Dict[str, Any] = { + "queries": { + "request": [{ + "searchTerms": "python releases" + }] + }, + "items": [ + { + "title": "Python 3.13 Release Notes", + "link": "https://python.org/releases/3.13", + "snippet": "Release notes for Python 3.13.", + "pagemap": { + "metatags": [{ + "description": "Official release notes", + "og:description": "Python 3.13 official", + }], + }, + }, + { + "title": "Python on Wikipedia", + "link": "https://en.wikipedia.org/wiki/Python", + "snippet": "Encyclopedia entry for Python.", + }, + ], +} + + +class TestGoogleProvider: + + @pytest.mark.asyncio + async def test_missing_credentials_returns_helpful_result(self): + t = WebSearchTool(provider="google", api_key="", engine_id="") + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={"query": "python"}, + ) + assert res["provider"] == "google" + assert res["results"] == [] + assert "not configured" in res["summary"] + + @pytest.mark.asyncio + async def test_happy_path(self): + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="fake-key", + engine_id="fake-cx", + http_client=client, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "lang": "en", + }, + ) + assert res["provider"] == "google" + assert [h["url"] for h in res["results"]] == [ + "https://python.org/releases/3.13", + "https://en.wikipedia.org/wiki/Python", + ] + # Metatag enrichment is folded into the snippet. + assert "Official release notes" in res["results"][0]["snippet"] + # Effective query is surfaced because it differs from user query. + assert "python releases" in res["summary"] + + # Inspect the last request — lang + key + cx should be attached. + req = client._captured["last_request"] + assert req.url.params.get("hl") == "en" + assert req.url.params.get("key") == "fake-key" + assert req.url.params.get("cx") == "fake-cx" + await client.aclose() + + @pytest.mark.asyncio + async def test_single_allowed_domain_maps_to_server_side_filter(self): + """Exactly ONE allowed domain → offload to Google's siteSearch.""" + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + ) + await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["python.org"], + }, + ) + req = client._captured["last_request"] + assert req.url.params.get("siteSearch") == "python.org" + assert req.url.params.get("siteSearchFilter") == "i" + await client.aclose() + + @pytest.mark.asyncio + async def test_single_blocked_domain_maps_to_server_side_filter(self): + """Exactly ONE blocked domain → offload to Google's siteSearch.""" + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + ) + await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "blocked_domains": ["wikipedia.org"], + }, + ) + req = client._captured["last_request"] + assert req.url.params.get("siteSearch") == "wikipedia.org" + assert req.url.params.get("siteSearchFilter") == "e" + await client.aclose() + + @pytest.mark.asyncio + async def test_multi_allowed_domains_skip_server_filter_and_filter_clientside(self): + """Multiple allowed domains → no siteSearch, rely on client-side filter. + + Without this behaviour CSE would only return results from + ``allowed[0]`` and silently drop everything from ``allowed[1:]``. + """ + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "allowed_domains": ["python.org", "wikipedia.org"], + }, + ) + + # No server-side filter should be attached. + req = client._captured["last_request"] + assert req.url.params.get("siteSearch") is None + assert req.url.params.get("siteSearchFilter") is None + + # Client-side filter keeps only the whitelisted domains. + urls = [h["url"] for h in res["results"]] + assert "https://python.org/releases/3.13" in urls + assert "https://en.wikipedia.org/wiki/Python" in urls + await client.aclose() + + @pytest.mark.asyncio + async def test_multi_blocked_domains_skip_server_filter_and_filter_clientside(self): + """Multiple blocked domains → no siteSearch, rely on client-side filter.""" + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "blocked_domains": ["wikipedia.org", "stackoverflow.com"], + }, + ) + + req = client._captured["last_request"] + assert req.url.params.get("siteSearch") is None + assert req.url.params.get("siteSearchFilter") is None + + # Client-side filter excludes blocked domains. + urls = [h["url"] for h in res["results"]] + assert "https://en.wikipedia.org/wiki/Python" not in urls + assert "https://python.org/releases/3.13" in urls + await client.aclose() + + @pytest.mark.asyncio + async def test_results_are_deduplicated_by_url(self): + """Google CSE rarely emits duplicates on its own, but ``http``/ + ``https`` and trailing-slash variants still slip through (and + ``google_extra_params`` can produce them); the tool must collapse + them. + """ + body = { + "queries": { + "request": [{ + "searchTerms": "python" + }] + }, + "items": [ + { + "title": "Python docs", + "link": "https://docs.python.org/3", + "snippet": "first", + }, + { + "title": "Python docs (dup)", + "link": "https://www.docs.python.org/3/", + "snippet": "duplicate", + }, + { + "title": "PEP 8", + "link": "https://peps.python.org/pep-0008/", + "snippet": "style", + }, + ], + } + client = _make_mock_client({"/customsearch/v1": body}) + t = WebSearchTool(provider="google", api_key="k", engine_id="cx", http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + assert urls == [ + "https://docs.python.org/3", + "https://peps.python.org/pep-0008/", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_dedup_urls_false_preserves_duplicates(self): + body = { + "queries": { + "request": [{ + "searchTerms": "python" + }] + }, + "items": [ + { + "title": "Python docs", + "link": "https://docs.python.org/3", + "snippet": "first", + }, + { + "title": "Python docs (dup)", + "link": "https://www.docs.python.org/3/", + "snippet": "duplicate", + }, + ], + } + client = _make_mock_client({"/customsearch/v1": body}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + dedup_urls=False, + ) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + urls = [h["url"] for h in res["results"]] + assert urls == [ + "https://docs.python.org/3", + "https://www.docs.python.org/3/", + ] + await client.aclose() + + @pytest.mark.asyncio + async def test_google_num_clamped_to_provider_hard_cap(self): + """Google CSE rejects ``num > 10`` — the tool must clamp server-side. + + The tool-level ``count`` / ``_MAX_COUNT`` can legitimately go up to 20, + but when the provider is Google that value MUST be clamped to 10 in + the outgoing request to avoid a 400 from CSE. + """ + client = _make_mock_client({"/customsearch/v1": _GOOGLE_RESPONSE}) + t = WebSearchTool( + provider="google", + api_key="k", + engine_id="cx", + http_client=client, + ) + await t._run_async_impl( + tool_context=_tool_ctx(), + args={ + "query": "python", + "count": 20, + }, + ) + req = client._captured["last_request"] + assert req.url.params.get("num") == "10" + await client.aclose() + + @pytest.mark.asyncio + async def test_api_error_surfaced_as_structured_result(self): + error_body = { + "error": { + "code": 403, + "message": "API key invalid", + } + } + client = _make_mock_client({"/customsearch/v1": error_body}) + t = WebSearchTool( + provider="google", + api_key="bad", + engine_id="cx", + http_client=client, + ) + res = await t._run_async_impl( + tool_context=_tool_ctx(), + args={"query": "python"}, + ) + assert res["results"] == [] + assert "API key invalid" in res["summary"] + await client.aclose() + + +class TestHttpErrorHandling: + + @pytest.mark.asyncio + async def test_http_error_returns_structured_error(self): + + def boom(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="server on fire") + + client = httpx.AsyncClient(transport=httpx.MockTransport(boom)) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + assert "error" in res + assert "HTTP_ERROR" in res["error"] + assert res["provider"] == "duckduckgo" + await client.aclose() + + +class TestInjectedHttpClient: + + @pytest.mark.asyncio + async def test_injected_client_still_uses_tool_user_agent(self): + """A pre-built ``http_client`` must NOT bypass the tool's User-Agent. + + The injected client owns proxy + connection pool, but the + per-request ``User-Agent`` (and timeout) must come from the tool + so that callers can't accidentally fingerprint the wrong identity + just by sharing a client. + """ + client = _make_mock_client({"/": _MIN_DDG_RESPONSE}) + t = WebSearchTool(http_client=client, user_agent="custom-ua/9.9") + await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + req = client._captured["last_request"] + assert req.headers.get("user-agent") == "custom-ua/9.9" + await client.aclose() + + +class TestProcessRequest: + + @pytest.mark.asyncio + async def test_registers_declaration_and_instructions(self): + tool = WebSearchTool() + req = LlmRequest() + await tool.process_request(tool_context=_tool_ctx(), llm_request=req) + + assert tool.name in req.tools_dict + assert req.config is not None + assert req.config.system_instruction + si = str(req.config.system_instruction) + assert _TOOL_NAME in si + assert "Sources:" in si + # Current month/year string should be embedded. + assert _current_month_year() in si + + +class TestHelpers: + + def test_truncate_short(self): + assert _truncate("abc", 10) == "abc" + + def test_truncate_long(self): + assert _truncate("x" * 50, 10) == "xxxxxxxxxx..." + + def test_extract_domain_from_url(self): + assert _extract_domain_from_url("https://www.python.org/x") == "python.org" + assert _extract_domain_from_url("http://docs.python.org/x") == "docs.python.org" + assert _extract_domain_from_url("") == "" + + def test_extract_domain_from_url_strips_port_and_userinfo(self): + # Explicit ports used to leak into the host via the old regex + # (``example.com:8080``), which made allow/block matching silently + # fail. ``urlparse`` correctly strips them. + assert _extract_domain_from_url("https://example.com:8080/x") == "example.com" + assert _extract_domain_from_url("https://user:pass@example.com/x") == "example.com" + # Mixed case normalises to lower. + assert _extract_domain_from_url("HTTPS://WWW.Example.COM/x") == "example.com" + + def test_extract_domain_from_url_non_http_schemes_fail_closed(self): + for bad in ("javascript:alert(1)", "ftp://foo/bar", "mailto:a@b.c", "not a url"): + assert _extract_domain_from_url(bad) == "" + + def test_dedup_key_normalises_common_variants(self): + # Trailing slash, www., uppercase host all collapse to the same key. + a = _dedup_key("https://www.Example.com/path/") + b = _dedup_key("https://example.com/path") + assert a == b + # Fragments are ignored. + assert _dedup_key("https://x.com/y#section") == _dedup_key("https://x.com/y") + # Path case is preserved (case can be significant on some servers). + assert _dedup_key("https://x.com/A") != _dedup_key("https://x.com/a") + # Different schemes are NOT merged (http vs https are different + # destinations from a security/content standpoint). + assert _dedup_key("http://x.com/y") != _dedup_key("https://x.com/y") + # Unparseable URLs fall through to their stripped form. + assert _dedup_key(" ") == "" + assert _dedup_key("not a url") == "not a url" + # Query strings ARE part of the identity — a sitemap vs a search + # results page on the same path must not dedupe. + assert _dedup_key("https://x.com/s?q=1") != _dedup_key("https://x.com/s?q=2") + # Default ports collapse with their bare form. + assert _dedup_key("https://x.com:443/y") == _dedup_key("https://x.com/y") + assert _dedup_key("http://x.com:80/y") == _dedup_key("http://x.com/y") + # Non-default ports are preserved (different service). + assert _dedup_key("https://x.com:8443/y") != _dedup_key("https://x.com/y") + + def test_is_blocked_with_blocklist(self): + assert _is_blocked("https://docs.python.org/x", None, ["python.org"]) + assert _is_blocked("https://python.org/x", None, ["python.org"]) + assert not _is_blocked("https://example.com/x", None, ["python.org"]) + + def test_is_blocked_with_allowlist(self): + assert not _is_blocked("https://python.org/x", ["python.org"], None) + assert _is_blocked("https://example.com/x", ["python.org"], None) + + def test_is_blocked_no_lists_passes(self): + assert not _is_blocked("https://x.com", None, None) + + def test_is_blocked_invalid_url_fails_closed(self): + # URLs we can't parse a host out of should always be treated as blocked, + # so they don't slip past allow/block filters into the hit list. + for bad in ("", "not a url", "javascript:alert(1)", "ftp://foo/bar", "mailto:a@b.c"): + assert _is_blocked(bad, None, None) is True + assert _is_blocked(bad, ["example.com"], None) is True + assert _is_blocked(bad, None, ["example.com"]) is True + + def test_extract_title_from_ddg_topic(self): + assert _extract_title_from_ddg_topic("Python - the language") == "Python" + assert _extract_title_from_ddg_topic("") == "" + long_title = "X" * 200 + out = _extract_title_from_ddg_topic(long_title) + assert out.endswith("...") + + def test_extract_desc_from_pagemap_ok(self): + pagemap = { + "metatags": [ + { + "description": "first" + }, + { + "og:description": "second" + }, + {}, + ] + } + assert _extract_desc_from_pagemap(pagemap) == "first\nsecond" + + def test_extract_desc_from_pagemap_bad_shapes(self): + assert _extract_desc_from_pagemap({}) == "" + assert _extract_desc_from_pagemap({"metatags": "not-a-list"}) == "" + assert _extract_desc_from_pagemap({"metatags": [None, 42]}) == "" + + +class TestOutputShape: + + @pytest.mark.asyncio + async def test_output_is_jsonable_dict(self): + client = _make_mock_client({"/": _FULL_DDG_RESPONSE}) + t = WebSearchTool(http_client=client) + res = await t._run_async_impl(tool_context=_tool_ctx(), args={"query": "python"}) + # Must be JSON-serialisable so the framework can place it in tool_result. + dumped = json.dumps(res) + parsed = json.loads(dumped) + assert parsed["query"] == "python" + assert isinstance(parsed["results"], list) + await client.aclose() diff --git a/tests/tools/utils/__init__.py b/tests/tools/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/tools/utils/test_automatic_function_calling.py b/tests/tools/utils/test_automatic_function_calling.py new file mode 100644 index 000000000..53420f9ed --- /dev/null +++ b/tests/tools/utils/test_automatic_function_calling.py @@ -0,0 +1,163 @@ +# 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. + +from __future__ import annotations + +import inspect +from typing import Optional + +import pytest +from pydantic import BaseModel + +from trpc_agent_sdk.tools._constants import DEFAULT_API_VARIANT +from trpc_agent_sdk.tools.utils._automatic_function_calling import ( + build_function_declaration, + from_function_with_options, +) + + +# --- Test helpers --- + +def simple_func(name: str, age: int) -> str: + """A simple function.""" + return f"{name} is {age}" + + +def no_param_func() -> str: + """No params.""" + return "hello" + + +def func_with_defaults(name: str, count: int = 5) -> str: + """Func with defaults.""" + return f"{name}-{count}" + + +def func_with_tool_context(query: str, tool_context) -> str: + """Has tool_context.""" + return query + + +def func_no_return(x: str): + """No return annotation.""" + pass + + +class InputModel(BaseModel): + query: str + limit: int = 10 + + +# --- Tests for from_function_with_options --- + +class TestFromFunctionWithOptions: + + def test_basic_function(self): + decl = from_function_with_options(simple_func) + assert decl.name == "simple_func" + assert decl.description == "A simple function." + assert decl.parameters is not None + assert "name" in decl.parameters.properties + assert "age" in decl.parameters.properties + + def test_no_params(self): + decl = from_function_with_options(no_param_func) + assert decl.name == "no_param_func" + assert decl.parameters is None + + def test_unsupported_variant_raises(self): + with pytest.raises(ValueError, match="Unsupported variant"): + from_function_with_options(simple_func, variant="bad_variant") + + def test_supported_variants(self): + decl = from_function_with_options( + simple_func, + variant=DEFAULT_API_VARIANT, + supported_variants=[DEFAULT_API_VARIANT], + ) + assert decl is not None + + def test_with_required_variant(self): + decl = from_function_with_options( + simple_func, + variant=DEFAULT_API_VARIANT, + required=DEFAULT_API_VARIANT, + ) + assert decl.parameters is not None + assert decl.parameters.required is not None + + def test_return_annotation_with_required(self): + def typed_func(x: str) -> str: + """Returns string.""" + return x + + decl = from_function_with_options( + typed_func, + variant=DEFAULT_API_VARIANT, + required=DEFAULT_API_VARIANT, + ) + assert decl.response is not None + + def test_no_return_annotation_with_required(self): + decl = from_function_with_options( + func_no_return, + variant=DEFAULT_API_VARIANT, + required=DEFAULT_API_VARIANT, + ) + assert decl.response is None + + def test_non_required_variant_skips_response(self): + def typed_func(x: str) -> str: + """Returns string.""" + return x + + decl = from_function_with_options(typed_func, variant=DEFAULT_API_VARIANT) + assert decl.response is None + + +# --- Tests for build_function_declaration --- + +class TestBuildFunctionDeclaration: + + def test_basic_function(self): + decl = build_function_declaration(simple_func) + assert decl.name == "simple_func" + assert "name" in decl.parameters.properties + assert "age" in decl.parameters.properties + + def test_ignore_params(self): + decl = build_function_declaration( + func_with_tool_context, + ignore_params=["tool_context"], + ) + assert decl.parameters.properties is not None + assert "tool_context" not in decl.parameters.properties + assert "query" in decl.parameters.properties + + def test_ignore_empty_list(self): + decl = build_function_declaration(simple_func, ignore_params=[]) + assert "name" in decl.parameters.properties + + def test_ignore_none(self): + decl = build_function_declaration(simple_func, ignore_params=None) + assert "name" in decl.parameters.properties + + def test_with_base_model(self): + decl = build_function_declaration(InputModel) + assert decl is not None + + def test_ignore_param_from_base_model(self): + decl = build_function_declaration(InputModel, ignore_params=["limit"]) + assert decl.parameters is not None + assert "limit" not in decl.parameters.properties + + def test_no_params_to_ignore(self): + decl = build_function_declaration(no_param_func) + assert decl.name == "no_param_func" + + def test_with_variant(self): + decl = build_function_declaration(simple_func, variant=DEFAULT_API_VARIANT) + assert decl is not None diff --git a/tests/tools/utils/test_function_parameter_parse.py b/tests/tools/utils/test_function_parameter_parse.py new file mode 100644 index 000000000..edd2983c6 --- /dev/null +++ b/tests/tools/utils/test_function_parameter_parse.py @@ -0,0 +1,337 @@ +# 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. + +from __future__ import annotations + +import inspect +from typing import Any, Dict, List, Literal, Optional, Union + +import pytest +from pydantic import BaseModel + +from trpc_agent_sdk.tools._constants import DEFAULT_API_VARIANT +from trpc_agent_sdk.tools.utils._function_parameter_parse import ( + SCHEMA_FIELDS, + _get_schema_fields, + _is_builtin_primitive_or_compound, + _is_default_value_compatible, + _resolve_annotation, + get_required_fields, + parse_schema_from_parameter, + register_checker, +) +from google.genai.types import Schema, Type + + +# --- Test _resolve_annotation --- + +class TestResolveAnnotation: + + def test_non_string_passthrough(self): + assert _resolve_annotation(str) is str + assert _resolve_annotation(int) is int + + def test_string_builtin_type(self): + assert _resolve_annotation("str") is str + assert _resolve_annotation("int") is int + assert _resolve_annotation("float") is float + assert _resolve_annotation("bool") is bool + + def test_string_with_globals(self): + class MyType: + pass + + result = _resolve_annotation("MyType", {"MyType": MyType}) + assert result is MyType + + def test_unresolvable_string(self): + result = _resolve_annotation("CompletelyUnknownType") + assert result == "CompletelyUnknownType" + + def test_invocation_context_string(self): + result = _resolve_annotation("InvocationContext") + assert result is Any + + +# --- Test _is_builtin_primitive_or_compound --- + +class TestIsBuiltinPrimitiveOrCompound: + + def test_builtin_types(self): + assert _is_builtin_primitive_or_compound(str) is True + assert _is_builtin_primitive_or_compound(int) is True + assert _is_builtin_primitive_or_compound(float) is True + assert _is_builtin_primitive_or_compound(bool) is True + assert _is_builtin_primitive_or_compound(list) is True + assert _is_builtin_primitive_or_compound(dict) is True + assert _is_builtin_primitive_or_compound(Any) is True + + def test_non_builtin_types(self): + assert _is_builtin_primitive_or_compound(BaseModel) is False + assert _is_builtin_primitive_or_compound(type(None)) is False + + +# --- Test _is_default_value_compatible --- + +class TestIsDefaultValueCompatible: + + def test_str_compatible(self): + assert _is_default_value_compatible("hello", str) is True + assert _is_default_value_compatible(42, str) is False + + def test_int_compatible(self): + assert _is_default_value_compatible(42, int) is True + assert _is_default_value_compatible("no", int) is False + + def test_float_compatible(self): + assert _is_default_value_compatible(1.5, float) is True + + def test_bool_compatible(self): + assert _is_default_value_compatible(True, bool) is True + + def test_dict_compatible(self): + assert _is_default_value_compatible({}, dict) is True + assert _is_default_value_compatible("no", dict) is False + + def test_union_type(self): + assert _is_default_value_compatible("hello", Union[str, int]) is True + assert _is_default_value_compatible(42, Union[str, int]) is True + assert _is_default_value_compatible(1.5, Union[str, int]) is False + + def test_list_type(self): + assert _is_default_value_compatible([1, 2], List[int]) is True + assert _is_default_value_compatible("not_list", List[int]) is False + + def test_dict_generic(self): + assert _is_default_value_compatible({}, Dict[str, int]) is True + assert _is_default_value_compatible("no", Dict[str, int]) is False + + def test_literal_type(self): + assert _is_default_value_compatible("a", Literal["a", "b"]) is True + assert _is_default_value_compatible("c", Literal["a", "b"]) is False + + def test_complex_list_union(self): + assert _is_default_value_compatible([1, "a", 1.1, True], List[Union[int, str, float, bool]]) is True + + def test_unrecognized_returns_false(self): + class Custom: + pass + assert _is_default_value_compatible(Custom(), Custom) is False + + +# --- Test parse_schema_from_parameter --- + +class SampleModel(BaseModel): + name: str + age: int + + +class TestParseSchemaFromParameter: + + def _make_param(self, name, annotation, default=inspect.Parameter.empty): + return inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation) + + def test_str_param(self): + param = self._make_param("x", str) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.STRING + + def test_int_param(self): + param = self._make_param("x", int) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.INTEGER + + def test_float_param(self): + param = self._make_param("x", float) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.NUMBER + + def test_bool_param(self): + param = self._make_param("x", bool) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.BOOLEAN + + def test_list_param(self): + param = self._make_param("x", list) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.ARRAY + + def test_dict_param(self): + param = self._make_param("x", dict) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.OBJECT + + def test_str_with_default(self): + param = self._make_param("x", str, default="hello") + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.STRING + assert schema.default == "hello" + + def test_incompatible_default_raises(self): + param = self._make_param("x", str, default=42) + with pytest.raises(ValueError, match="not compatible"): + parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + + def test_union_simple(self): + param = self._make_param("x", Union[str, int]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.any_of is not None + assert len(schema.any_of) == 2 + + def test_optional_type(self): + param = self._make_param("x", Optional[str]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.nullable is True + + def test_dict_generic(self): + param = self._make_param("x", Dict[str, int]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.OBJECT + + def test_list_generic(self): + param = self._make_param("x", List[str]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.ARRAY + assert schema.items is not None + assert schema.items.type == Type.STRING + + def test_literal(self): + param = self._make_param("x", Literal["a", "b", "c"]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.STRING + assert schema.enum == ["a", "b", "c"] + + def test_literal_non_string_raises(self): + param = self._make_param("x", Literal[1, 2]) + with pytest.raises(ValueError, match="must be a list of strings"): + parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + + def test_pydantic_model(self): + param = self._make_param("x", SampleModel) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.OBJECT + assert "name" in schema.properties + assert "age" in schema.properties + + def test_unsupported_type_raises(self): + class CustomClass: + pass + param = self._make_param("x", CustomClass) + with pytest.raises(ValueError, match="Failed to parse"): + parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + + def test_string_annotation_resolution(self): + param = self._make_param("x", "str") + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.type == Type.STRING + + def test_optional_list(self): + param = self._make_param("x", Optional[List[str]]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.nullable is True + + def test_union_with_default(self): + param = self._make_param("x", Union[str, int], default="hello") + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.default == "hello" + + def test_literal_with_default(self): + param = self._make_param("x", Literal["a", "b"], default="a") + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.default == "a" + + def test_dict_generic_with_default(self): + param = self._make_param("x", Dict[str, int], default={}) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.default == {} + + def test_list_generic_with_default(self): + param = self._make_param("x", List[str], default=[]) + schema = parse_schema_from_parameter(DEFAULT_API_VARIANT, param, "test_func") + assert schema.default == [] + + +# --- Test get_required_fields --- + +class TestGetRequiredFields: + + def test_basic_required_fields(self): + schema = Schema( + type=Type.OBJECT, + properties={ + "name": Schema(type=Type.STRING), + "age": Schema(type=Type.INTEGER), + }, + ) + required = get_required_fields(schema) + assert "name" in required + assert "age" in required + + def test_nullable_field_not_required(self): + schema = Schema( + type=Type.OBJECT, + properties={ + "name": Schema(type=Type.STRING), + "optional_field": Schema(type=Type.STRING, nullable=True), + }, + ) + required = get_required_fields(schema) + assert "name" in required + assert "optional_field" not in required + + def test_field_with_default_not_required(self): + schema = Schema( + type=Type.OBJECT, + properties={ + "name": Schema(type=Type.STRING), + "count": Schema(type=Type.INTEGER, default=0), + }, + ) + required = get_required_fields(schema) + assert "name" in required + assert "count" not in required + + def test_no_properties_returns_none(self): + schema = Schema(type=Type.OBJECT) + assert get_required_fields(schema) is None + + +# --- Test register_checker --- + +class TestRegisterChecker: + + def test_register_and_use_checker(self): + test_variant = f"test_variant_{id(self)}" + checker_called = False + + @register_checker(test_variant) + def my_checker(schema: Schema) -> bool: + nonlocal checker_called + checker_called = True + return True + + from trpc_agent_sdk.tools.utils._function_parameter_parse import _SchemaChecker + checker = _SchemaChecker() + result = checker.check(test_variant, Schema(type=Type.STRING)) + assert checker_called + assert result is True + + +# --- Test SCHEMA_FIELDS --- + +class TestSchemaFields: + + def test_schema_fields_is_set(self): + assert isinstance(SCHEMA_FIELDS, set) + + def test_contains_common_fields(self): + assert "type" in SCHEMA_FIELDS + assert "properties" in SCHEMA_FIELDS + + def test_get_schema_fields_returns_set(self): + result = _get_schema_fields() + assert isinstance(result, set) + assert len(result) > 0 diff --git a/tests/tools/utils/test_tool_utils.py b/tests/tools/utils/test_tool_utils.py new file mode 100644 index 000000000..93ec3374e --- /dev/null +++ b/tests/tools/utils/test_tool_utils.py @@ -0,0 +1,246 @@ +# 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. + +import inspect +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio +import pydantic +import pytest + +from trpc_agent_sdk.tools.utils._tool_utils import ( + convert_pydantic_args, + extract_text, + get_mandatory_args, + retry_on_closed_resource, +) +from trpc_agent_sdk.types import Content, MemoryEntry, Part + + +# --- Test extract_text --- + +class TestExtractText: + + def test_extract_single_part(self): + entry = MagicMock(spec=MemoryEntry) + entry.content = Content(parts=[Part.from_text(text="hello")]) + assert extract_text(entry) == "hello" + + def test_extract_multiple_parts(self): + entry = MagicMock(spec=MemoryEntry) + entry.content = Content(parts=[ + Part.from_text(text="hello"), + Part.from_text(text="world"), + ]) + assert extract_text(entry) == "hello world" + + def test_extract_with_custom_splitter(self): + entry = MagicMock(spec=MemoryEntry) + entry.content = Content(parts=[ + Part.from_text(text="a"), + Part.from_text(text="b"), + ]) + assert extract_text(entry, splitter=",") == "a,b" + + def test_extract_empty_parts(self): + entry = MagicMock(spec=MemoryEntry) + entry.content = Content(parts=[]) + assert extract_text(entry) == "" + + def test_extract_parts_with_none_text(self): + entry = MagicMock(spec=MemoryEntry) + part_with_text = Part.from_text(text="hello") + part_no_text = MagicMock(spec=Part) + part_no_text.text = None + entry.content = Content(parts=[part_with_text, part_no_text]) + assert extract_text(entry) == "hello" + + +# --- Test get_mandatory_args --- + +class TestGetMandatoryArgs: + + def test_all_mandatory(self): + def func(a: str, b: int): + pass + + assert get_mandatory_args(func) == ["a", "b"] + + def test_with_defaults(self): + def func(a: str, b: int = 10): + pass + + assert get_mandatory_args(func) == ["a"] + + def test_no_args(self): + def func(): + pass + + assert get_mandatory_args(func) == [] + + def test_excludes_var_positional(self): + def func(a: str, *args): + pass + + assert get_mandatory_args(func) == ["a"] + + def test_excludes_var_keyword(self): + def func(a: str, **kwargs): + pass + + assert get_mandatory_args(func) == ["a"] + + def test_keyword_only(self): + def func(*, a: str, b: int = 5): + pass + + assert get_mandatory_args(func) == ["a"] + + def test_mixed(self): + def func(a: str, b: int = 5, *args, c: str, **kwargs): + pass + + assert get_mandatory_args(func) == ["a", "c"] + + +# --- Test convert_pydantic_args --- + +class SampleModel(pydantic.BaseModel): + name: str + value: int + + +class TestConvertPydanticArgs: + + def test_convert_dict_to_model(self): + def func(data: SampleModel): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"data": {"name": "test", "value": 42}}, sig) + assert isinstance(result["data"], SampleModel) + assert result["data"].name == "test" + + def test_already_model_instance(self): + def func(data: SampleModel): + pass + + sig = inspect.signature(func) + model = SampleModel(name="ok", value=1) + result = convert_pydantic_args({"data": model}, sig) + assert result["data"] is model + + def test_non_pydantic_param(self): + def func(x: str): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"x": "hello"}, sig) + assert result["x"] == "hello" + + def test_unknown_param_passed_through(self): + def func(x: str): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"x": "hello", "extra": "val"}, sig) + assert result["extra"] == "val" + + def test_no_annotation(self): + def func(x): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"x": "hello"}, sig) + assert result["x"] == "hello" + + def test_validation_error_keeps_original(self): + def func(data: SampleModel): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"data": {"name": "test"}}, sig) + # Missing 'value' field - validation error, keeps original dict + assert isinstance(result["data"], dict) or isinstance(result["data"], SampleModel) + + def test_non_dict_non_instance_fallback(self): + def func(data: SampleModel): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"data": "invalid"}, sig) + # Should keep original value on failure + assert result["data"] == "invalid" + + def test_non_class_annotation(self): + def func(x: int | str): + pass + + sig = inspect.signature(func) + result = convert_pydantic_args({"x": 42}, sig) + assert result["x"] == 42 + + +# --- Test retry_on_closed_resource --- + +class TestRetryOnClosedResource: + + @pytest.mark.asyncio + async def test_no_retry_on_success(self): + call_count = 0 + + class Svc: + @retry_on_closed_resource + async def action(self): + nonlocal call_count + call_count += 1 + return "ok" + + svc = Svc() + result = await svc.action() + assert result == "ok" + assert call_count == 1 + + @pytest.mark.asyncio + async def test_retries_on_closed_resource(self): + call_count = 0 + + class Svc: + @retry_on_closed_resource + async def action(self): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise anyio.ClosedResourceError() + return "retried_ok" + + svc = Svc() + result = await svc.action() + assert result == "retried_ok" + assert call_count == 2 + + @pytest.mark.asyncio + async def test_does_not_retry_on_other_errors(self): + class Svc: + @retry_on_closed_resource + async def action(self): + raise ValueError("not closed resource") + + svc = Svc() + with pytest.raises(ValueError, match="not closed resource"): + await svc.action() + + @pytest.mark.asyncio + async def test_preserves_function_metadata(self): + class Svc: + @retry_on_closed_resource + async def my_action(self): + """My doc.""" + pass + + assert Svc.my_action.__name__ == "my_action" + assert Svc.my_action.__doc__ == "My doc." diff --git a/tests/trpc_agent_dsl/__init__.py b/tests/trpc_agent_dsl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/trpc_agent_dsl/graph/__init__.py b/tests/trpc_agent_dsl/graph/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/trpc_agent_dsl/graph/test_callbacks.py b/tests/trpc_agent_dsl/graph/test_callbacks.py new file mode 100644 index 000000000..8ebe61b21 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_callbacks.py @@ -0,0 +1,116 @@ +# 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 graph node callback utilities.""" + +from datetime import datetime + +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbackContext +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbacks +from trpc_agent_sdk.dsl.graph._callbacks import create_logging_callbacks +from trpc_agent_sdk.dsl.graph._callbacks import merge_callbacks + + +class _InMemoryLogger: + """Simple logger double that records info logs.""" + + def __init__(self): + self.messages: list[str] = [] + + def info(self, message: str) -> None: + self.messages.append(message) + + +class TestNodeCallbackContext: + """Tests for callback execution context.""" + + def test_post_init_populates_default_fields(self): + """Context should auto-fill node_name and execution_start_time.""" + ctx = NodeCallbackContext(node_id="planner", node_name="") + + assert ctx.node_name == "planner" + assert isinstance(ctx.execution_start_time, datetime) + + +class TestCallbackMerging: + """Tests for global/node callback merge behavior.""" + + def test_merge_callbacks_respects_phase_specific_order(self): + """Before/error callbacks run global-first while after runs node-first.""" + + async def global_before(ctx, state): + del ctx, state + return None + + async def node_before(ctx, state): + del ctx, state + return None + + async def global_after(ctx, state, result, error): + del ctx, state, result, error + return None + + async def node_after(ctx, state, result, error): + del ctx, state, result, error + return None + + async def global_error(ctx, state, error): + del ctx, state, error + + async def node_error(ctx, state, error): + del ctx, state, error + + async def global_agent_event(ctx, state, event): + del ctx, state, event + + async def node_agent_event(ctx, state, event): + del ctx, state, event + + global_callbacks = NodeCallbacks( + before_node=[global_before], + after_node=[global_after], + on_error=[global_error], + agent_event=[global_agent_event], + ) + node_callbacks = NodeCallbacks( + before_node=[node_before], + after_node=[node_after], + on_error=[node_error], + agent_event=[node_agent_event], + ) + + merged = merge_callbacks(global_callbacks, node_callbacks) + + assert merged is not None + assert merged.before_node == [global_before, node_before] + assert merged.after_node == [node_after, global_after] + assert merged.on_error == [global_error, node_error] + assert merged.agent_event == [global_agent_event, node_agent_event] + + +class TestLoggingCallbacks: + """Tests for convenience logging callback factory.""" + + async def test_create_logging_callbacks_emit_expected_messages(self): + """Generated callbacks should log before/after/error lifecycle points.""" + logger = _InMemoryLogger() + callbacks = create_logging_callbacks(logger=logger) + ctx = NodeCallbackContext(node_id="summarizer") + + await callbacks.before_node[0](ctx, {}) + await callbacks.after_node[0](ctx, {}, {"ok": True}, None) + await callbacks.on_error[0](ctx, {}, RuntimeError("boom")) + + assert len(logger.messages) == 3 + assert "Starting node execution" in logger.messages[0] + assert "Completed in" in logger.messages[1] + assert "Error: boom" in logger.messages[2] + + async def test_create_logging_callbacks_without_logger_uses_print_path(self): + """When logger is absent, callbacks should fall back to print().""" + callbacks = create_logging_callbacks(logger=None, log_after=False, log_errors=False) + ctx = NodeCallbackContext(node_id="printer") + + await callbacks.before_node[0](ctx, {}) diff --git a/tests/trpc_agent_dsl/graph/test_constants.py b/tests/trpc_agent_dsl/graph/test_constants.py new file mode 100644 index 000000000..5dc561a29 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_constants.py @@ -0,0 +1,178 @@ +# 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 graph state key constants and is_unsafe_state_key.""" + +from trpc_agent_sdk.dsl.graph._constants import ( + END, + METADATA_KEY_AGENT_NAME, + METADATA_KEY_BRANCH, + METADATA_KEY_INVOCATION_ID, + METADATA_KEY_SESSION_ID, + NODE_TYPE_AGENT, + NODE_TYPE_CODE, + NODE_TYPE_FUNCTION, + NODE_TYPE_KNOWLEDGE, + NODE_TYPE_LLM, + NODE_TYPE_TOOL, + ROLE_FUNCTION, + ROLE_MODEL, + ROLE_SYSTEM, + ROLE_USER, + START, + STATE_KEY_AGENT_CALLBACKS, + STATE_KEY_CHECKPOINT_BLOBS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CURRENT_NODE_ID, + STATE_KEY_EXEC_CONTEXT, + STATE_KEY_LAST_RESPONSE, + STATE_KEY_LAST_RESPONSE_ID, + STATE_KEY_LAST_TOOL_RESPONSE, + STATE_KEY_LONG_RUNNING_PREFIX, + STATE_KEY_MESSAGES, + STATE_KEY_METADATA, + STATE_KEY_MODEL_CALLBACKS, + STATE_KEY_NODE_CALLBACKS, + STATE_KEY_NODE_RESPONSES, + STATE_KEY_ONE_SHOT_MESSAGES, + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, + STATE_KEY_PENDING_INTERRUPT, + STATE_KEY_PENDING_INTERRUPT_AUTHOR, + STATE_KEY_PENDING_INTERRUPT_BRANCH, + STATE_KEY_PENDING_INTERRUPT_ID, + STATE_KEY_SESSION, + STATE_KEY_STEP_NUMBER, + STATE_KEY_TOOL_CALLBACKS, + STATE_KEY_USER_INPUT, + STREAM_KEY_ACK, + STREAM_KEY_EVENT, + UNSAFE_STATE_KEYS, + is_unsafe_state_key, +) + + +class TestStateKeyValues: + """Verify state key string literals are stable (guards against accidental renames).""" + + def test_core_state_keys(self): + assert STATE_KEY_USER_INPUT == "user_input" + assert STATE_KEY_MESSAGES == "messages" + assert STATE_KEY_LAST_RESPONSE == "last_response" + assert STATE_KEY_LAST_RESPONSE_ID == "last_response_id" + assert STATE_KEY_LAST_TOOL_RESPONSE == "last_tool_response" + assert STATE_KEY_NODE_RESPONSES == "node_responses" + + def test_one_shot_message_keys(self): + assert STATE_KEY_ONE_SHOT_MESSAGES == "one_shot_messages" + assert STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE == "one_shot_messages_by_node" + + def test_metadata_and_context_keys(self): + assert STATE_KEY_METADATA == "metadata" + assert STATE_KEY_SESSION == "session" + assert STATE_KEY_CURRENT_NODE_ID == "current_node_id" + assert STATE_KEY_EXEC_CONTEXT == "exec_context" + + def test_callback_keys(self): + assert STATE_KEY_TOOL_CALLBACKS == "tool_callbacks" + assert STATE_KEY_MODEL_CALLBACKS == "model_callbacks" + assert STATE_KEY_AGENT_CALLBACKS == "agent_callbacks" + assert STATE_KEY_NODE_CALLBACKS == "node_callbacks" + + def test_metadata_sub_keys(self): + assert METADATA_KEY_INVOCATION_ID == "invocation_id" + assert METADATA_KEY_SESSION_ID == "session_id" + assert METADATA_KEY_BRANCH == "branch" + assert METADATA_KEY_AGENT_NAME == "agent_name" + + def test_node_type_values(self): + assert NODE_TYPE_FUNCTION == "function" + assert NODE_TYPE_LLM == "llm" + assert NODE_TYPE_TOOL == "tool" + assert NODE_TYPE_AGENT == "agent" + assert NODE_TYPE_CODE == "code" + assert NODE_TYPE_KNOWLEDGE == "knowledge" + + def test_graph_boundary_constants(self): + assert START == "__start__" + assert END == "__end__" + + def test_step_and_stream_keys(self): + assert STATE_KEY_STEP_NUMBER == "step_number" + assert STREAM_KEY_EVENT == "_trpc_graph_event" + assert STREAM_KEY_ACK == "_trpc_graph_ack" + + def test_checkpoint_keys(self): + assert STATE_KEY_CHECKPOINTS == "_trpc_graph_checkpoints" + assert STATE_KEY_CHECKPOINT_WRITES == "_trpc_graph_checkpoint_writes" + assert STATE_KEY_CHECKPOINT_BLOBS == "_trpc_graph_checkpoint_blobs" + + def test_interrupt_keys(self): + assert STATE_KEY_PENDING_INTERRUPT == "_trpc_graph_pending_interrupt" + assert STATE_KEY_PENDING_INTERRUPT_ID == "_trpc_graph_pending_interrupt_id" + assert STATE_KEY_PENDING_INTERRUPT_AUTHOR == "_trpc_graph_pending_interrupt_author" + assert STATE_KEY_PENDING_INTERRUPT_BRANCH == "_trpc_graph_pending_interrupt_branch" + assert STATE_KEY_LONG_RUNNING_PREFIX == "__trpc_graph_long_running__" + + def test_role_values(self): + assert ROLE_USER == "user" + assert ROLE_MODEL == "model" + assert ROLE_FUNCTION == "function" + assert ROLE_SYSTEM == "system" + + +class TestUnsafeStateKeys: + """Tests for the UNSAFE_STATE_KEYS set and is_unsafe_state_key function.""" + + def test_unsafe_keys_is_frozenset(self): + assert isinstance(UNSAFE_STATE_KEYS, frozenset) + + def test_unsafe_keys_contains_expected_members(self): + expected = { + STATE_KEY_SESSION, + STATE_KEY_EXEC_CONTEXT, + STATE_KEY_CURRENT_NODE_ID, + STATE_KEY_TOOL_CALLBACKS, + STATE_KEY_MODEL_CALLBACKS, + STATE_KEY_AGENT_CALLBACKS, + STATE_KEY_NODE_CALLBACKS, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINT_BLOBS, + STATE_KEY_PENDING_INTERRUPT, + STATE_KEY_PENDING_INTERRUPT_ID, + STATE_KEY_PENDING_INTERRUPT_AUTHOR, + STATE_KEY_PENDING_INTERRUPT_BRANCH, + } + assert UNSAFE_STATE_KEYS == expected + + def test_is_unsafe_state_key_returns_true_for_unsafe_keys(self): + for key in UNSAFE_STATE_KEYS: + assert is_unsafe_state_key(key) is True, f"Expected {key!r} to be unsafe" + + def test_is_unsafe_state_key_returns_false_for_safe_keys(self): + safe_keys = [ + STATE_KEY_USER_INPUT, + STATE_KEY_MESSAGES, + STATE_KEY_LAST_RESPONSE, + STATE_KEY_NODE_RESPONSES, + STATE_KEY_METADATA, + STATE_KEY_STEP_NUMBER, + "arbitrary_custom_key", + "", + ] + for key in safe_keys: + assert is_unsafe_state_key(key) is False, f"Expected {key!r} to be safe" + + def test_serializable_keys_are_not_unsafe(self): + """Keys that represent user-visible data should never appear in UNSAFE_STATE_KEYS.""" + serializable = { + STATE_KEY_USER_INPUT, STATE_KEY_MESSAGES, STATE_KEY_LAST_RESPONSE, + STATE_KEY_LAST_RESPONSE_ID, STATE_KEY_LAST_TOOL_RESPONSE, + STATE_KEY_NODE_RESPONSES, STATE_KEY_ONE_SHOT_MESSAGES, + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, STATE_KEY_METADATA, + STATE_KEY_STEP_NUMBER, + } + assert serializable.isdisjoint(UNSAFE_STATE_KEYS) diff --git a/tests/trpc_agent_dsl/graph/test_event_metadata.py b/tests/trpc_agent_dsl/graph/test_event_metadata.py new file mode 100644 index 000000000..7c60296ed --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_event_metadata.py @@ -0,0 +1,76 @@ +# 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. +"""Public metadata extraction tests for graph events.""" + +from datetime import datetime + +from trpc_agent_sdk.dsl.graph._events import EventBuilder +from trpc_agent_sdk.dsl.graph._events import EventUtils +from trpc_agent_sdk.dsl.graph._events import ExecutionPhase + + +class TestEventMetadata: + """Tests metadata availability through EventBuilder and EventUtils.""" + + def test_node_events_expose_typed_node_metadata(self): + """Node start/complete events should carry structured node metadata.""" + builder = EventBuilder(invocation_id="inv-1", author="worker", branch="root.worker") + start_event = builder.node_start( + node_id="worker", + node_type="function", + step_number=2, + input_keys=["question"], + ) + complete_event = builder.node_complete( + node_id="worker", + node_type="function", + step_number=2, + start_time=datetime.now(), + output_keys=["answer"], + ) + + start_meta = EventUtils.get_node_metadata(start_event) + complete_meta = EventUtils.get_node_metadata(complete_event) + + assert start_meta is not None + assert start_meta.node_id == "worker" + assert start_meta.phase == ExecutionPhase.START.value + assert start_meta.input_keys == ["question"] + + assert complete_meta is not None + assert complete_meta.node_id == "worker" + assert complete_meta.phase == ExecutionPhase.COMPLETE.value + assert complete_meta.output_keys == ["answer"] + + def test_model_and_tool_events_expose_typed_metadata(self): + """Model/tool completion events should expose typed metadata with phase info.""" + builder = EventBuilder(invocation_id="inv-1", author="worker", branch="root.worker") + model_event = builder.model_complete( + model_name="mock-model", + node_id="worker", + start_time=datetime.now(), + input_text="hello", + output_text="world", + ) + tool_event = builder.tool_complete( + tool_name="search", + tool_id="tool-1", + node_id="worker", + start_time=datetime.now(), + input_args='{"q":"x"}', + output_result='{"ok":true}', + ) + + model_meta = EventUtils.get_model_metadata(model_event) + tool_meta = EventUtils.get_tool_metadata(tool_event) + + assert model_meta is not None + assert model_meta.model_name == "mock-model" + assert model_meta.phase == ExecutionPhase.COMPLETE.value + + assert tool_meta is not None + assert tool_meta.tool_name == "search" + assert tool_meta.phase == ExecutionPhase.COMPLETE.value diff --git a/tests/trpc_agent_dsl/graph/test_event_writer.py b/tests/trpc_agent_dsl/graph/test_event_writer.py new file mode 100644 index 000000000..9c456f4e3 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_event_writer.py @@ -0,0 +1,184 @@ +# 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 graph event writers.""" + +from google.genai.types import Content +from google.genai.types import Part +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._events import EventUtils +from trpc_agent_sdk.dsl.graph._events import ExecutionPhase +from trpc_agent_sdk.events import Event + + +class _AckingStreamWriter: + """Captures stream payloads and resolves ack futures immediately.""" + + def __init__(self): + self.payloads: list[dict] = [] + + def __call__(self, payload: dict) -> None: + self.payloads.append(payload) + ack = payload.get(STREAM_KEY_ACK) + if ack is not None and not ack.done(): + ack.set_result(True) + + +class TestEventWriter: + """Tests for synchronous event writer.""" + + def test_write_text_applies_optional_context_fields(self): + """Text events should carry request/parent/filter context.""" + payloads: list[dict] = [] + writer = EventWriter( + writer=payloads.append, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + request_id="req-1", + parent_invocation_id="parent-1", + filter_key="root.node-a", + ) + + writer.write_text("hello", partial=False) + + assert len(payloads) == 1 + event = payloads[0][STREAM_KEY_EVENT] + assert event.get_text() == "hello" + assert event.partial is False + assert event.request_id == "req-1" + assert event.parent_invocation_id == "parent-1" + assert event.filter_key == "root.node-a" + + def test_write_event_forwards_existing_event_instance(self): + """Direct write_event should emit the exact event object.""" + payloads: list[dict] = [] + writer = EventWriter( + writer=payloads.append, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + event = Event( + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + content=Content(role="model", parts=[Part.from_text(text="raw")]), + ) + + writer.write_event(event) + + assert payloads[0][STREAM_KEY_EVENT] is event + + def test_write_content_and_properties_expose_base_context(self): + """write_content should emit content as-is and properties should be readable.""" + payloads: list[dict] = [] + writer = EventWriter( + writer=payloads.append, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + content = Content(role="model", parts=[Part.from_text(text="payload")]) + + writer.write_content(content, partial=True) + + emitted = payloads[0][STREAM_KEY_EVENT] + assert emitted.content == content + assert emitted.partial is True + assert writer.invocation_id == "inv-1" + assert writer.author == "node-a" + assert writer.branch == "root.node-a" + assert writer.builder is not None + + +class TestAsyncEventWriter: + """Tests for asynchronous event writer lifecycle behavior.""" + + async def test_write_text_flushes_with_ack_future(self): + """Async writes should include an ack future and wait for completion.""" + stream_writer = _AckingStreamWriter() + writer = AsyncEventWriter( + writer=stream_writer, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + + await writer.write_text("chunk", partial=True) + + assert len(stream_writer.payloads) == 1 + assert STREAM_KEY_ACK in stream_writer.payloads[0] + assert stream_writer.payloads[0][STREAM_KEY_EVENT].partial is True + + async def test_node_lifecycle_events_reuse_cached_start_time(self): + """Node complete events should include timing from prior node start.""" + stream_writer = _AckingStreamWriter() + writer = AsyncEventWriter( + writer=stream_writer, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + + await writer.write_node_start("node-a", step_number=3, input_keys=["messages"]) + await writer.write_node_complete("node-a", step_number=3, output_keys=["last_response"]) + + complete_event = stream_writer.payloads[-1][STREAM_KEY_EVENT] + metadata = EventUtils.get_node_metadata(complete_event) + + assert metadata is not None + assert metadata.phase == ExecutionPhase.COMPLETE.value + assert metadata.step_number == 3 + assert metadata.start_time is not None + + async def test_tool_complete_clears_cached_tool_start_time(self): + """Tool completion should remove the tool start timestamp cache entry.""" + stream_writer = _AckingStreamWriter() + writer = AsyncEventWriter( + writer=stream_writer, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + + await writer.write_tool_start("calculator", "tool-1", input_args='{"x": 1}') + assert "tool-1" in writer._tool_start_times + + await writer.write_tool_complete("calculator", "tool-1", output_result="42") + + assert "tool-1" not in writer._tool_start_times + complete_event = stream_writer.payloads[-1][STREAM_KEY_EVENT] + metadata = EventUtils.get_tool_metadata(complete_event) + + assert metadata is not None + assert metadata.phase == ExecutionPhase.COMPLETE.value + assert metadata.output_result == "42" + + async def test_write_node_error_and_model_events_emit_expected_metadata(self): + """Error/model lifecycle APIs should emit graph execution events.""" + stream_writer = _AckingStreamWriter() + writer = AsyncEventWriter( + writer=stream_writer, + invocation_id="inv-1", + author="node-a", + branch="root.node-a", + ) + + await writer.write_node_start("node-a") + await writer.write_node_error("node-a", "failure") + await writer.write_model_start("model-a", input_text="input text") + await writer.write_model_complete("model-a", output_text="output text") + + node_error = stream_writer.payloads[1][STREAM_KEY_EVENT] + model_start = stream_writer.payloads[2][STREAM_KEY_EVENT] + model_complete = stream_writer.payloads[3][STREAM_KEY_EVENT] + + assert EventUtils.get_event_type(node_error) == "graph.node.error" + assert EventUtils.get_model_metadata(model_start).phase == "start" + assert EventUtils.get_model_metadata(model_complete).phase == "complete" diff --git a/tests/trpc_agent_dsl/graph/test_events.py b/tests/trpc_agent_dsl/graph/test_events.py new file mode 100644 index 000000000..fe3cbc8e5 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_events.py @@ -0,0 +1,322 @@ +# 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 graph event building and extraction.""" + +from datetime import datetime +from datetime import timedelta + +from google.genai.types import Content +from google.genai.types import Part +from trpc_agent_sdk.dsl.graph._events import EventBuilder +from trpc_agent_sdk.dsl.graph._events import EventUtils +from trpc_agent_sdk.dsl.graph._events import ExecutionPhase +from trpc_agent_sdk.dsl.graph._events import GraphEventType +from trpc_agent_sdk.dsl.graph._events import METADATA_KEY_NODE +from trpc_agent_sdk.dsl.graph._events import METADATA_KEY_STATE +from trpc_agent_sdk.dsl.graph._events import StateUpdateMetadata +from trpc_agent_sdk.dsl.graph._events._metadata import ModelExecutionMetadata +from trpc_agent_sdk.dsl.graph._events._metadata import NodeExecutionMetadata +from trpc_agent_sdk.dsl.graph._events._metadata import ToolExecutionMetadata +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.types import EventActions + + +class TestEventBuilder: + """Tests for event creation behavior.""" + + def setup_method(self): + self.builder = EventBuilder( + invocation_id="inv-1", + author="planner", + branch="root.planner", + ) + + def test_node_start_contains_structured_metadata_and_truncates_model_input(self): + """Node start should carry metadata and expose graph object type.""" + event = self.builder.node_start( + node_id="planner", + step_number=2, + input_keys=["messages"], + model_name="test-model", + model_input="x" * 700, + ) + + metadata = EventUtils.get_node_metadata(event) + + assert metadata is not None + assert metadata.node_id == "planner" + assert metadata.phase == ExecutionPhase.START.value + assert metadata.input_keys == ["messages"] + assert len(metadata.model_input or "") == 500 + assert event.object == GraphEventType.GRAPH_NODE_START + assert event.partial is True + + def test_model_complete_uses_error_phase_and_truncates_payloads(self): + """Model completion should switch to error phase when error is provided.""" + start_time = datetime.now() - timedelta(milliseconds=20) + event = self.builder.model_complete( + model_name="test-model", + node_id="planner", + start_time=start_time, + input_text="i" * 800, + output_text="o" * 700, + error="timeout", + ) + + metadata = EventUtils.get_model_metadata(event) + + assert metadata is not None + assert metadata.phase == ExecutionPhase.ERROR.value + assert metadata.error == "timeout" + assert len(metadata.input_text or "") == 500 + assert len(metadata.output_text or "") == 500 + assert EventUtils.get_duration_ms(event) > 0 + assert "failed" in event.get_text() + + def test_tool_complete_tracks_duration_and_truncates_arguments(self): + """Tool completion should include bounded args/result fields and duration.""" + start_time = datetime.now() - timedelta(milliseconds=10) + event = self.builder.tool_complete( + tool_name="search", + tool_id="tool-1", + node_id="planner", + start_time=start_time, + input_args="a" * 1200, + output_result="b" * 1400, + ) + + metadata = EventUtils.get_tool_metadata(event) + + assert metadata is not None + assert metadata.phase == ExecutionPhase.COMPLETE.value + assert len(metadata.input_args or "") == 1000 + assert len(metadata.output_result or "") == 1000 + assert metadata.duration_ms > 0 + assert event.object == GraphEventType.GRAPH_NODE_EXECUTION + + def test_state_update_builds_state_metadata_and_removed_count_text(self): + """State update event should expose updated/removed keys in metadata and text.""" + event = self.builder.state_update( + updated_keys=["a", "b"], + removed_keys=["c"], + state_size=3, + ) + + metadata = EventUtils.get_metadata(event, METADATA_KEY_STATE, StateUpdateMetadata) + + assert metadata is not None + assert metadata.updated_keys == ["a", "b"] + assert metadata.removed_keys == ["c"] + assert metadata.state_size == 3 + assert event.object == GraphEventType.GRAPH_STATE_UPDATE + assert event.partial is True + assert "1 removed" in event.get_text() + + def test_model_start_sets_partial_execution_event(self): + """Model start should emit partial graph execution event metadata.""" + event = self.builder.model_start( + model_name="test-model", + node_id="planner", + input_text="prompt text", + step_number=7, + ) + + metadata = EventUtils.get_model_metadata(event) + + assert metadata is not None + assert metadata.model_name == "test-model" + assert metadata.phase == ExecutionPhase.START.value + assert metadata.step_number == 7 + assert event.partial is True + assert event.object == GraphEventType.GRAPH_NODE_EXECUTION + + def test_graph_complete_handles_success_and_error_payloads(self): + """Graph completion should report phase, keys, and optional error details.""" + start_time = datetime.now() - timedelta(milliseconds=50) + + success = self.builder.graph_complete( + total_steps=3, + start_time=start_time, + final_state={"a": 1}, + state_delta={"b": 2}, + error=None, + ) + failed = self.builder.graph_complete( + total_steps=2, + start_time=start_time, + final_state={}, + state_delta={}, + error="boom", + ) + + assert success.object == GraphEventType.GRAPH_EXECUTION + assert success.actions.state_delta["phase"] == ExecutionPhase.COMPLETE.value + assert success.actions.state_delta["final_state_keys"] == ["a"] + assert success.actions.state_delta["state_delta_keys"] == ["b"] + assert failed.actions.state_delta["phase"] == ExecutionPhase.ERROR.value + assert failed.actions.state_delta["error"] == "boom" + assert "failed" in failed.get_text() + + +class TestEventUtils: + """Tests for graph event extraction helpers.""" + + def test_utils_detect_graph_events_by_object_type(self): + """Helpers should detect graph events via object type.""" + event = Event( + invocation_id="inv-1", + author="planner", + object="graph.node.start", + content=Content(role="model", parts=[Part.from_text(text="legacy")]), + actions=EventActions(state_delta={ + "node_id": "node-x", + "duration_ms": 12.5, + }), + ) + + assert EventUtils.is_graph_event(event) is True + assert EventUtils.get_event_type(event) == "graph.node.start" + assert EventUtils.get_node_id(event) == "node-x" + assert EventUtils.get_duration_ms(event) == 12.5 + + def test_get_node_metadata_returns_none_on_invalid_metadata_shape(self): + """Invalid metadata payloads should fail safely instead of raising.""" + event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="bad")]), + actions=EventActions(state_delta={ + METADATA_KEY_NODE: "invalid", + }), + ) + + assert EventUtils.get_node_metadata(event) is None + + def test_utils_fall_back_to_non_graph_defaults(self): + """Helpers should return safe defaults for non-graph/non-metadata events.""" + event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="plain")]), + ) + + assert EventUtils.is_graph_event(event) is False + assert EventUtils.get_event_type(event) is None + assert EventUtils.get_node_id(event) is None + assert EventUtils.get_duration_ms(event) == 0.0 + + def test_get_node_id_prefers_tool_and_model_metadata_then_legacy_fallback(self): + """Node id extraction should support tool/model metadata and legacy keys.""" + tool_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="tool")]), + actions=EventActions(state_delta={"_tool_metadata": { + "node_id": "tool-node", + }}), + ) + model_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="model")]), + actions=EventActions(state_delta={"_model_metadata": { + "node_id": "model-node", + }}), + ) + legacy_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="legacy")]), + actions=EventActions(state_delta={"node_id": "legacy-node"}), + ) + + assert EventUtils.get_node_id(tool_event) == "tool-node" + assert EventUtils.get_node_id(model_event) == "model-node" + assert EventUtils.get_node_id(legacy_event) == "legacy-node" + + def test_get_metadata_returns_none_for_invalid_dict_payload(self): + """Malformed metadata dicts should fail closed without raising.""" + event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="bad")]), + actions=EventActions(state_delta={"_model_metadata": { + "model_name": "x", + }}), + ) + + assert EventUtils.get_metadata(event, "_model_metadata", ModelExecutionMetadata) is None + + def test_node_metadata_from_event_converts_phase_string_to_enum(self): + """Dataclass helper should normalize serialized enum values from events.""" + event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="ok")]), + actions=EventActions( + state_delta={METADATA_KEY_NODE: { + "node_id": "node-x", + "node_type": "function", + "phase": "complete", + }}), + ) + + metadata = NodeExecutionMetadata.from_event(event) + + assert metadata is not None + assert metadata.phase == ExecutionPhase.COMPLETE + + def test_tool_and_model_metadata_from_event_cover_success_and_failure_paths(self): + """Tool/model metadata extractors should parse valid payloads and reject invalid ones.""" + tool_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="tool")]), + actions=EventActions(state_delta={ + "_tool_metadata": { + "tool_name": "search", + "tool_id": "t-1", + "node_id": "planner", + "phase": "start", + } + }), + ) + invalid_tool_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="tool")]), + actions=EventActions(state_delta={"_tool_metadata": "invalid"}), + ) + + model_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="model")]), + actions=EventActions( + state_delta={"_model_metadata": { + "model_name": "gpt", + "node_id": "planner", + "phase": "error", + }}), + ) + invalid_model_event = Event( + invocation_id="inv-1", + author="planner", + content=Content(role="model", parts=[Part.from_text(text="model")]), + actions=EventActions(state_delta={"_model_metadata": { + "phase": "invalid" + }}), + ) + + tool_metadata = ToolExecutionMetadata.from_event(tool_event) + model_metadata = ModelExecutionMetadata.from_event(model_event) + + assert tool_metadata is not None + assert tool_metadata.phase == ExecutionPhase.START + assert ToolExecutionMetadata.from_event(invalid_tool_event) is None + assert model_metadata is not None + assert model_metadata.phase == ExecutionPhase.ERROR + assert ModelExecutionMetadata.from_event(invalid_model_event) is None diff --git a/tests/trpc_agent_dsl/graph/test_events_constants.py b/tests/trpc_agent_dsl/graph/test_events_constants.py new file mode 100644 index 000000000..c48d0a37b --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_events_constants.py @@ -0,0 +1,101 @@ +# 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 graph event constants and enums.""" + +from trpc_agent_sdk.dsl.graph._events._constants import ( + GRAPH_EXECUTION_KEY_END_TIME, + GRAPH_EXECUTION_KEY_ERROR, + GRAPH_EXECUTION_KEY_FINAL_STATE_KEYS, + GRAPH_EXECUTION_KEY_PHASE, + GRAPH_EXECUTION_KEY_START_TIME, + GRAPH_EXECUTION_KEY_STATE_DELTA_KEYS, + GRAPH_EXECUTION_KEY_TOTAL_DURATION_MS, + GRAPH_EXECUTION_KEY_TOTAL_STEPS, + METADATA_KEY_MODEL, + METADATA_KEY_NODE, + METADATA_KEY_STATE, + METADATA_KEY_TOOL, + ExecutionPhase, + GraphEventType, +) + + +class TestMetadataKeyConstants: + """Verify metadata key string literals are stable.""" + + def test_metadata_key_values(self): + assert METADATA_KEY_NODE == "_node_metadata" + assert METADATA_KEY_TOOL == "_tool_metadata" + assert METADATA_KEY_MODEL == "_model_metadata" + assert METADATA_KEY_STATE == "_state_metadata" + + +class TestGraphExecutionKeyConstants: + """Verify graph execution state delta key string literals.""" + + def test_execution_key_values(self): + assert GRAPH_EXECUTION_KEY_PHASE == "phase" + assert GRAPH_EXECUTION_KEY_TOTAL_STEPS == "total_steps" + assert GRAPH_EXECUTION_KEY_TOTAL_DURATION_MS == "total_duration_ms" + assert GRAPH_EXECUTION_KEY_START_TIME == "start_time" + assert GRAPH_EXECUTION_KEY_END_TIME == "end_time" + assert GRAPH_EXECUTION_KEY_FINAL_STATE_KEYS == "final_state_keys" + assert GRAPH_EXECUTION_KEY_STATE_DELTA_KEYS == "state_delta_keys" + assert GRAPH_EXECUTION_KEY_ERROR == "error" + + +class TestGraphEventType: + """Tests for GraphEventType enum.""" + + def test_is_string_enum(self): + assert isinstance(GraphEventType.GRAPH_EXECUTION, str) + + def test_enum_values(self): + assert GraphEventType.GRAPH_EXECUTION == "graph.execution" + assert GraphEventType.GRAPH_NODE_EXECUTION == "graph.node.execution" + assert GraphEventType.GRAPH_NODE_START == "graph.node.start" + assert GraphEventType.GRAPH_NODE_COMPLETE == "graph.node.complete" + assert GraphEventType.GRAPH_NODE_ERROR == "graph.node.error" + assert GraphEventType.GRAPH_STATE_UPDATE == "graph.state.update" + + def test_all_members_start_with_graph_prefix(self): + for member in GraphEventType: + assert member.value.startswith("graph."), f"{member.name} should start with 'graph.'" + + def test_member_count(self): + assert len(GraphEventType) == 6 + + def test_can_be_constructed_from_value(self): + assert GraphEventType("graph.execution") is GraphEventType.GRAPH_EXECUTION + assert GraphEventType("graph.node.start") is GraphEventType.GRAPH_NODE_START + + def test_string_comparison(self): + assert GraphEventType.GRAPH_EXECUTION == "graph.execution" + assert "graph.node.error" == GraphEventType.GRAPH_NODE_ERROR + + +class TestExecutionPhase: + """Tests for ExecutionPhase enum.""" + + def test_is_string_enum(self): + assert isinstance(ExecutionPhase.START, str) + + def test_enum_values(self): + assert ExecutionPhase.START == "start" + assert ExecutionPhase.COMPLETE == "complete" + assert ExecutionPhase.ERROR == "error" + + def test_member_count(self): + assert len(ExecutionPhase) == 3 + + def test_can_be_constructed_from_value(self): + assert ExecutionPhase("start") is ExecutionPhase.START + assert ExecutionPhase("complete") is ExecutionPhase.COMPLETE + assert ExecutionPhase("error") is ExecutionPhase.ERROR + + def test_string_comparison(self): + assert ExecutionPhase.START == "start" + assert "error" == ExecutionPhase.ERROR diff --git a/tests/trpc_agent_dsl/graph/test_graph_agent.py b/tests/trpc_agent_dsl/graph/test_graph_agent.py new file mode 100644 index 000000000..89f260ce3 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_graph_agent.py @@ -0,0 +1,303 @@ +# 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 GraphAgent public run behavior.""" + +import asyncio +from dataclasses import dataclass +from typing import Any + +import pytest +from google.genai.types import Content +from google.genai.types import Part +from langgraph.types import Command +from langgraph.types import Interrupt +from pydantic import ValidationError +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.dsl.graph._constants import ROLE_USER +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINTS +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LONG_RUNNING_PREFIX +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_INTERRUPT +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_INTERRUPT_AUTHOR +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_INTERRUPT_BRANCH +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_INTERRUPT_ID +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._graph_agent import GraphAgent +from trpc_agent_sdk.dsl.graph._state_graph import CompiledStateGraph +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import FunctionResponse + + +@dataclass +class _RecordedCall: + """Captured invocation data for fake compiled graph calls.""" + + graph_input: Any + config: dict[str, Any] + stream_mode: list[str] | tuple[str, ...] + + +class _FakeCompiledGraph: + """Simple compiled graph stub with scripted stream outputs.""" + + def __init__(self, items: list[tuple[str, Any]] | None = None, error: Exception | None = None): + self._items = items or [] + self._error = error + self.calls: list[_RecordedCall] = [] + + async def astream( + self, + graph_input: Any, + config: dict[str, Any], + *, + stream_mode: list[str] | tuple[str, ...], + ): + self.calls.append(_RecordedCall(graph_input=graph_input, config=config, stream_mode=stream_mode)) + if self._error is not None: + raise self._error + for item in self._items: + yield item + + +def _new_graph_agent(fake_graph: _FakeCompiledGraph) -> GraphAgent: + """Create a GraphAgent bound to a fake compiled graph.""" + compiled = CompiledStateGraph(fake_graph, object()) + return GraphAgent(name="graph-agent", graph=compiled) + + +def _new_session(*events: Event, state: dict[str, Any] | None = None) -> Session: + """Create a minimal Session for GraphAgent tests.""" + return Session( + id="session-1", + app_name="app", + user_id="user", + save_key="save-key", + state=state or {}, + events=list(events), + ) + + +def _new_invocation_context( + agent: GraphAgent, + session: Session, + *, + invocation_id: str = "inv-1", + branch: str | None = "graph-agent", + actions: EventActions | None = None, +) -> InvocationContext: + """Create a real InvocationContext for GraphAgent.run_async tests.""" + return InvocationContext( + session_service=InMemorySessionService(), + invocation_id=invocation_id, + branch=branch, + agent=agent, + agent_context=new_agent_context(), + session=session, + event_actions=actions or EventActions(), + ) + + +@pytest.mark.asyncio +class TestGraphAgent: + """Public behavior tests for GraphAgent.run_async.""" + + async def test_rejects_sub_agents_constructor_field(self): + """GraphAgent should reject BaseAgent sub_agents construction field.""" + compiled = CompiledStateGraph(_FakeCompiledGraph(items=[]), object()) + with pytest.raises(ValidationError, match="does not accept `sub_agents`"): + GraphAgent( + name="graph-agent", + graph=compiled, + sub_agents=[], + ) + + async def test_run_async_streams_updates_custom_events_and_completion(self): + """GraphAgent should pass through stream events and emit graph execution completion.""" + custom_event = Event( + invocation_id="inv-1", + author="worker", + content=Content(role="model", parts=[Part.from_text(text="chunk")]), + partial=False, + ) + ack = asyncio.get_running_loop().create_future() + graph = _FakeCompiledGraph(items=[ + ( + "updates", + { + "worker": { + "value": 1, + STATE_KEY_LAST_RESPONSE: "done", + } + }, + ), + ( + "custom", + { + STREAM_KEY_EVENT: custom_event, + STREAM_KEY_ACK: ack, + }, + ), + ]) + agent = _new_graph_agent(graph) + user_event = Event( + invocation_id="inv-1", + author=ROLE_USER, + content=Content(role=ROLE_USER, parts=[Part.from_text(text="hello")]), + ) + session = _new_session(user_event) + ctx = _new_invocation_context( + agent, + session, + actions=EventActions(state_delta={"persisted": "delta"}), + ) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 3 + assert events[0].object == "graph.state.update" + assert events[1].get_text() == "chunk" + completion = events[2] + assert completion.object == "graph.execution" + assert completion.actions is not None + assert completion.actions.state_delta[STATE_KEY_LAST_RESPONSE] == "done" + assert completion.actions.state_delta["persisted"] == "delta" + assert completion.actions.state_delta["value"] == 1 + assert completion.actions.state_delta["phase"] == "complete" + assert ack.done() is True + + assert len(graph.calls) == 1 + call = graph.calls[0] + assert call.stream_mode == ["updates", "custom"] + assert isinstance(call.graph_input, dict) + assert call.graph_input[STATE_KEY_USER_INPUT] == "hello" + assert call.graph_input[STATE_KEY_MESSAGES] == [] + + async def test_run_async_uses_checkpoint_state_without_replaying_history(self): + """When checkpoint exists, GraphAgent should not rebuild full message history.""" + graph = _FakeCompiledGraph(items=[]) + agent = _new_graph_agent(graph) + user_event = Event( + invocation_id="inv-1", + author=ROLE_USER, + content=Content(role=ROLE_USER, parts=[Part.from_text(text="latest")]), + ) + session = _new_session( + user_event, + state={ + STATE_KEY_CHECKPOINTS: { + "thread": {} + }, + STATE_KEY_MESSAGES: [Content(role="model", parts=[Part.from_text(text="stale")])], + }, + ) + ctx = _new_invocation_context(agent, session) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 1 + assert events[0].object == "graph.execution" + assert events[0].actions is not None + assert events[0].actions.state_delta["phase"] == "complete" + call = graph.calls[0] + assert isinstance(call.graph_input, dict) + assert STATE_KEY_MESSAGES not in call.graph_input + assert call.graph_input[STATE_KEY_USER_INPUT] == "latest" + + async def test_run_async_interrupt_emits_bridge_events_and_pauses_completion(self): + """Interrupt output should produce bridge events and skip graph completion.""" + interrupt = Interrupt(value={"prompt": "approve"}, id="approval_finance_interrupt") + graph = _FakeCompiledGraph(items=[("updates", {"__interrupt__": interrupt})]) + agent = _new_graph_agent(graph) + session = _new_session() + ctx = _new_invocation_context( + agent, + session, + branch="root.graph", + actions=EventActions(state_delta={"trace": "x"}), + ) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 3 + assert events[0].get_function_calls()[0].name == "graph_interrupt" + assert events[1].get_function_responses()[0].response == {"prompt": "approve"} + assert isinstance(events[2], LongRunningEvent) + assert all(event.object != "graph.execution" for event in events) + + function_call_event = events[0] + assert function_call_event.actions is not None + assert function_call_event.actions.state_delta["trace"] == "x" + assert session.state[STATE_KEY_PENDING_INTERRUPT] is True + assert isinstance(session.state[STATE_KEY_PENDING_INTERRUPT_ID], str) + assert session.state[STATE_KEY_PENDING_INTERRUPT_ID].startswith(STATE_KEY_LONG_RUNNING_PREFIX) + assert session.state[STATE_KEY_PENDING_INTERRUPT_AUTHOR] == "graph-agent" + assert session.state[STATE_KEY_PENDING_INTERRUPT_BRANCH] == "root.graph" + + async def test_run_async_resume_uses_command_and_clears_pending_interrupt_state(self): + """User function response should resume graph and clear pending markers.""" + function_response = FunctionResponse( + id=f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:1", + name="approval", + response={"approved": True}, + ) + response_event = Event( + invocation_id="inv-1", + author=ROLE_USER, + content=Content(role=ROLE_USER, parts=[Part(function_response=function_response)]), + ) + graph = _FakeCompiledGraph(items=[]) + agent = _new_graph_agent(graph) + session = _new_session( + response_event, + state={ + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: function_response.id, + STATE_KEY_PENDING_INTERRUPT_AUTHOR: "graph-agent", + STATE_KEY_PENDING_INTERRUPT_BRANCH: "root.graph", + }, + ) + ctx = _new_invocation_context(agent, session, branch="root.graph") + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 1 + assert events[0].object == "graph.execution" + assert events[0].actions is not None + assert events[0].actions.state_delta[STATE_KEY_PENDING_INTERRUPT] is False + assert events[0].actions.state_delta[STATE_KEY_PENDING_INTERRUPT_ID] is None + assert events[0].actions.state_delta[STATE_KEY_PENDING_INTERRUPT_AUTHOR] is None + assert events[0].actions.state_delta[STATE_KEY_PENDING_INTERRUPT_BRANCH] is None + call = graph.calls[0] + assert isinstance(call.graph_input, Command) + assert call.graph_input.resume == {"approval:1": {"approved": True}} + assert session.state[STATE_KEY_PENDING_INTERRUPT] is False + assert session.state[STATE_KEY_PENDING_INTERRUPT_ID] is None + assert session.state[STATE_KEY_PENDING_INTERRUPT_AUTHOR] is None + assert session.state[STATE_KEY_PENDING_INTERRUPT_BRANCH] is None + + async def test_run_async_reports_stream_errors_in_completion_event(self): + """Stream errors should surface in graph execution completion metadata.""" + graph = _FakeCompiledGraph(error=RuntimeError("stream failed")) + agent = _new_graph_agent(graph) + session = _new_session() + ctx = _new_invocation_context(agent, session) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 1 + completion = events[0] + assert completion.object == "graph.execution" + assert completion.actions is not None + assert completion.actions.state_delta["phase"] == "error" + assert completion.actions.state_delta["error"] == "stream failed" diff --git a/tests/trpc_agent_dsl/graph/test_interrupt.py b/tests/trpc_agent_dsl/graph/test_interrupt.py new file mode 100644 index 000000000..6e7a50aa9 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_interrupt.py @@ -0,0 +1,24 @@ +# 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 graph interrupt wrapper.""" + +from unittest.mock import patch + +from trpc_agent_sdk.dsl.graph._interrupt import interrupt + + +class TestInterrupt: + """Tests for interrupt passthrough behavior.""" + + def test_interrupt_delegates_to_langgraph_interrupt(self): + """Wrapper should call langgraph interrupt function with original payload.""" + payload = {"need": "approval"} + + with patch("trpc_agent_sdk.dsl.graph._interrupt._langgraph_interrupt", return_value={"resume": True}) as mock_fn: + result = interrupt(payload) + + mock_fn.assert_called_once_with(payload) + assert result == {"resume": True} diff --git a/tests/trpc_agent_dsl/graph/test_memory_saver.py b/tests/trpc_agent_dsl/graph/test_memory_saver.py new file mode 100644 index 000000000..a099243a7 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_memory_saver.py @@ -0,0 +1,313 @@ +# 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. +"""Public-API tests for graph memory saver.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from langgraph.checkpoint.base import empty_checkpoint +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINTS +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINT_BLOBS +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINT_WRITES +from trpc_agent_sdk.dsl.graph._memory_saver import MemorySaver +from trpc_agent_sdk.dsl.graph._memory_saver import has_graph_internal_checkpoint_state +from trpc_agent_sdk.dsl.graph._memory_saver import strip_graph_internal_checkpoint_state + + +class TestMemorySaverHelpers: + """Tests for exported checkpoint-state helper functions.""" + + def test_has_and_strip_graph_internal_checkpoint_state(self): + """Strip helper should remove only graph internal checkpoint keys.""" + state = { + "visible": "keep", + STATE_KEY_CHECKPOINTS: { + "t": {} + }, + STATE_KEY_CHECKPOINT_BLOBS: { + "t": {} + }, + } + + stripped = strip_graph_internal_checkpoint_state(state) + + assert has_graph_internal_checkpoint_state(state) is True + assert stripped == {"visible": "keep"} + assert STATE_KEY_CHECKPOINTS in state + + +class TestMemorySaverStorage: + """Tests for MemorySaver public put/get/list/write/delete behavior.""" + + def test_put_and_get_tuple_round_trip_channel_values(self): + """Checkpoint put/get should preserve serialized channel values.""" + saver = MemorySaver() + config = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_ns": "graph-a", + } + } + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"answer": "42"} + checkpoint["channel_versions"] = {"answer": 1} + + next_config = saver.put( + config, + checkpoint, + metadata={ + "source": "loop", + "step": 1 + }, + new_versions={"answer": 1}, + ) + restored = saver.get_tuple(next_config) + + assert restored is not None + assert restored.config["configurable"]["checkpoint_id"] == checkpoint["id"] + assert restored.checkpoint["channel_values"]["answer"] == "42" + assert restored.metadata["step"] == 1 + + def test_put_writes_skips_duplicate_write_index_for_same_task(self): + """Repeated writes with same computed index should keep first value only.""" + saver = MemorySaver() + base_config = { + "configurable": { + "thread_id": "thread-1", + "checkpoint_ns": "graph-a", + } + } + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"answer": "42"} + checkpoint["channel_versions"] = {"answer": 1} + checkpoint_config = saver.put( + base_config, + checkpoint, + metadata={ + "source": "loop", + "step": 1 + }, + new_versions={"answer": 1}, + ) + + saver.put_writes(checkpoint_config, [("alpha", 1)], task_id="task-1") + saver.put_writes(checkpoint_config, [("alpha", 2)], task_id="task-1") + restored = saver.get_tuple(checkpoint_config) + + assert restored is not None + assert restored.pending_writes == [("task-1", "alpha", 1)] + + def test_put_uses_invocation_context_session_state_storage(self): + """InvocationContext session.state should be used as the storage backend.""" + saver = MemorySaver() + invocation_context = SimpleNamespace( + session=SimpleNamespace(state={}), + session_service=object(), + state={}, + ) + config = { + "configurable": { + "thread_id": "thread-ctx", + "checkpoint_ns": "graph-a", + "invocation_context": invocation_context, + } + } + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"answer": "42"} + checkpoint["channel_versions"] = {"answer": 1} + + saver.put(config, checkpoint, metadata={"step": 1}, new_versions={"answer": 1}) + + assert has_graph_internal_checkpoint_state(invocation_context.session.state) is True + + def test_put_supports_direct_session_state_injection(self): + """session_state config should be accepted as an alternate storage backend.""" + saver = MemorySaver() + session_state: dict[str, object] = {} + config = { + "configurable": { + "thread_id": "thread-injected", + "checkpoint_ns": "graph-a", + "session_state": session_state, + "session": "session-obj", + "session_service": "service-obj", + } + } + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"answer": "42"} + checkpoint["channel_versions"] = {"answer": 1} + + saver.put(config, checkpoint, metadata={"step": 1}, new_versions={"answer": 1}) + + assert has_graph_internal_checkpoint_state(session_state) is True + + def test_get_tuple_returns_none_for_missing_or_unknown_checkpoint(self): + """Tuple lookup should fail cleanly when no matching checkpoint exists.""" + saver = MemorySaver() + config = {"configurable": {"thread_id": "t1", "checkpoint_ns": "ns"}} + + assert saver.get_tuple(config) is None + + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-known" + saver.put(config, checkpoint, metadata={"step": 1}, new_versions={}) + + missing_config = {"configurable": {"thread_id": "t1", "checkpoint_ns": "ns", "checkpoint_id": "cp-missing"}} + assert saver.get_tuple(missing_config) is None + + def test_list_supports_filter_before_and_limit(self): + """Checkpoint listing should honor metadata filter, before, and limit options.""" + saver = MemorySaver() + config = {"configurable": {"thread_id": "t1", "checkpoint_ns": "ns"}} + + cp1 = empty_checkpoint() + cp1["id"] = "001" + cp2 = empty_checkpoint() + cp2["id"] = "002" + cp3 = empty_checkpoint() + cp3["id"] = "003" + + config1 = saver.put(config, cp1, metadata={"step": 1, "kind": "a"}, new_versions={}) + saver.put(config1, cp2, metadata={"step": 2, "kind": "b"}, new_versions={}) + saver.put(config1, cp3, metadata={"step": 3, "kind": "b"}, new_versions={}) + + filtered = list(saver.list(config, filter={"kind": "b"})) + assert [item.metadata["step"] for item in filtered] == [3, 2] + + before = {"configurable": {"thread_id": "t1", "checkpoint_ns": "ns", "checkpoint_id": "003"}} + before_items = list(saver.list(config, before=before)) + assert all(item.config["configurable"]["checkpoint_id"] != "003" for item in before_items) + + limited = list(saver.list(config, limit=1)) + assert len(limited) == 1 + + def test_delete_thread_removes_storage_for_known_and_unknown_contexts(self): + """Deleting thread should clear stored data regardless of context source.""" + saver = MemorySaver() + config = {"configurable": {"thread_id": "thread-delete", "checkpoint_ns": "ns"}} + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-1" + saver.put(config, checkpoint, metadata={"step": 1}, new_versions={}) + + saver.delete_thread("thread-delete") + assert saver.get_tuple({"configurable": {"thread_id": "thread-delete", "checkpoint_ns": "ns"}}) is None + + # Unknown thread id should not raise. + saver.delete_thread("unknown-thread") + + def test_put_handles_empty_channel_blob_without_materializing_value(self): + """Channels persisted as empty markers should not materialize during get_tuple.""" + saver = MemorySaver() + config = {"configurable": {"thread_id": "t-empty", "checkpoint_ns": "ns"}} + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-empty" + checkpoint["channel_values"] = {} + checkpoint["channel_versions"] = {"missing": 1} + + next_config = saver.put( + config, + checkpoint, + metadata={"step": 1}, + new_versions={"missing": 1}, + ) + restored = saver.get_tuple(next_config) + + assert restored is not None + assert restored.checkpoint["channel_values"] == {} + + +class TestMemorySaverAsync: + """Tests for asynchronous public APIs.""" + + async def test_async_methods_persist_when_auto_persist_and_persist_writes_enabled(self): + """aput/aput_writes/adelete_thread should persist in standalone mode when enabled.""" + saver = MemorySaver(auto_persist=True, persist_writes=True) + session_service = SimpleNamespace(update_session=AsyncMock()) + session = SimpleNamespace(state={}, id="session-1") + config = { + "configurable": { + "thread_id": "thread-async", + "checkpoint_ns": "ns", + "session_state": session.state, + "session": session, + "session_service": session_service, + } + } + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-async" + + next_config = await saver.aput(config, checkpoint, metadata={"step": 1}, new_versions={}) + writes_config = { + "configurable": { + **config["configurable"], + "checkpoint_id": next_config["configurable"]["checkpoint_id"], + } + } + await saver.aput_writes(writes_config, writes=[("x", 1)], task_id="task-1") + await saver.adelete_thread("thread-async") + + assert session_service.update_session.await_count == 3 + + async def test_async_methods_skip_persist_when_auto_persist_disabled(self): + """auto_persist=False should disable persistence even for async write APIs.""" + saver = MemorySaver(auto_persist=False, persist_writes=True) + session_service = SimpleNamespace(update_session=AsyncMock()) + session = SimpleNamespace(state={}, id="session-1") + config = { + "configurable": { + "thread_id": "thread-disabled", + "checkpoint_ns": "ns", + "session_state": session.state, + "session": session, + "session_service": session_service, + } + } + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-disabled" + + next_config = await saver.aput(config, checkpoint, metadata={"step": 1}, new_versions={}) + writes_config = { + "configurable": { + **config["configurable"], + "checkpoint_id": next_config["configurable"]["checkpoint_id"], + } + } + await saver.aput_writes(writes_config, writes=[("x", 1)], task_id="task-1") + await saver.adelete_thread("thread-disabled") + + assert session_service.update_session.await_count == 0 + + async def test_async_methods_skip_standalone_persist_with_invocation_context(self): + """When invocation_context is present, persistence should be skipped by MemorySaver.""" + saver = MemorySaver(auto_persist=True, persist_writes=True) + session_service = SimpleNamespace(update_session=AsyncMock()) + invocation_context = SimpleNamespace( + session=SimpleNamespace(state={}, id="session-1"), + session_service=session_service, + state={}, + ) + config = { + "configurable": { + "thread_id": "thread-ctx", + "checkpoint_ns": "ns", + "invocation_context": invocation_context, + } + } + checkpoint = empty_checkpoint() + checkpoint["id"] = "cp-ctx" + + next_config = await saver.aput(config, checkpoint, metadata={"step": 1}, new_versions={}) + writes_config = { + "configurable": { + **config["configurable"], + "checkpoint_id": next_config["configurable"]["checkpoint_id"], + } + } + await saver.aput_writes(writes_config, writes=[("x", 1)], task_id="task-1") + await saver.adelete_thread("thread-ctx") + + assert session_service.update_session.await_count == 0 diff --git a/tests/trpc_agent_dsl/graph/test_node_action_agent.py b/tests/trpc_agent_dsl/graph/test_node_action_agent.py new file mode 100644 index 000000000..588ae13c1 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_action_agent.py @@ -0,0 +1,394 @@ +# 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. +"""Execution-path tests for AgentNodeAction.""" + +from typing import Any + +import pytest +from google.genai.types import Content +from google.genai.types import Part +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbackContext +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbacks +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._node_action._agent import AgentNodeAction +from trpc_agent_sdk.dsl.graph._node_config import NodeConfig +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import EventActions + + +class _AckingWriter: + """Captures writer payloads and resolves async ack futures.""" + + def __init__(self): + self.payloads: list[dict] = [] + + def __call__(self, payload: dict) -> None: + self.payloads.append(payload) + ack = payload.get(STREAM_KEY_ACK) + if ack is not None and not ack.done(): + ack.set_result(True) + + +class _ScriptedAgent: + """Agent stub that yields scripted event batches per invocation.""" + + def __init__(self, name: str, event_batches: list[list[Event]]): + self.name = name + self._event_batches = event_batches + self.calls: list[Any] = [] + self.parent_agent = None + self.root_agent = None + + async def run_async(self, ctx): + call_index = len(self.calls) + self.calls.append(ctx) + events = self._event_batches[call_index] if call_index < len(self._event_batches) else [] + for event in events: + yield event + + +class _RootAgent: + """Root-agent stub exposing find_agent lookup.""" + + def __init__(self, name: str, mapping: dict[str, Any]): + self.name = name + self._mapping = mapping + + def find_agent(self, name: str): + return self._mapping.get(name) + + +class _FakeInvocationContext: + """Minimal invocation context double used by AgentNodeAction.""" + + def __init__(self, agent, session: Session, branch: str = "root"): + self.invocation_id = "inv-1" + self.branch = branch + self.agent = agent + self.session = session + self.user_content = None + self.event_actions = EventActions() + self.callback_state = None + self.override_messages = None + + def model_copy(self, update: dict[str, Any], deep: bool = False): + del deep + clone = _FakeInvocationContext(self.agent, self.session, self.branch) + clone.__dict__.update(self.__dict__) + for key, value in update.items(): + setattr(clone, key, value) + return clone + + +def _build_action( + agent, + config: NodeConfig, + *, + ctx: _FakeInvocationContext | None = None, + callbacks: NodeCallbacks | None = None, + isolated_messages: bool = False, + input_from_last_response: bool = False, + event_scope: str | None = None, + input_mapper=None, + output_mapper=None, +): + """Create AgentNodeAction with concrete event writers.""" + sink = _AckingWriter() + writer = EventWriter( + writer=sink, + invocation_id="inv-1", + author="node-1", + branch="root.node-1", + ) + async_writer = AsyncEventWriter( + writer=sink, + invocation_id="inv-1", + author="node-1", + branch="root.node-1", + ) + action = AgentNodeAction( + node_id="node-1", + agent=agent, + node_config=config, + writer=writer, + async_writer=async_writer, + ctx=ctx, + callback_ctx=NodeCallbackContext(node_id="node-1"), + callbacks=callbacks, + isolated_messages=isolated_messages, + input_from_last_response=input_from_last_response, + event_scope=event_scope, + input_mapper=input_mapper, + output_mapper=output_mapper, + ) + return action, sink + + +def _session_with_events(*events: Event) -> Session: + """Create session fixture for agent-node tests.""" + return Session( + id="session-1", + app_name="app", + user_id="user", + save_key="save-key", + state={}, + events=list(events), + ) + + +class TestAgentNodeActionExecute: + """Tests for execute flow and branching behavior.""" + + async def test_execute_requires_agent_and_invocation_context(self): + """Agent/context are required for execution.""" + config = NodeConfig(name="node-1") + + with pytest.raises(RuntimeError, match="is None"): + action, _ = _build_action(agent=None, config=config, ctx=None) + await action.execute({}) + + scripted_agent = _ScriptedAgent("child", [[]]) + action, _ = _build_action(agent=scripted_agent, config=config, ctx=None) + with pytest.raises(RuntimeError, match="requires InvocationContext"): + await action.execute({}) + + async def test_execute_returns_default_delta_and_emits_child_event(self): + """Visible child events should be forwarded and reflected in default result.""" + child_event = Event( + invocation_id="inv-1", + author="child", + content=Content(role="model", parts=[Part.from_text(text="child response")]), + partial=False, + actions=EventActions(state_delta={"x": 1}), + ) + scripted_agent = _ScriptedAgent("child", [[child_event]]) + session = _session_with_events() + ctx = _FakeInvocationContext(scripted_agent, session) + config = NodeConfig(name="node-1") + action, sink = _build_action(scripted_agent, config, ctx=ctx) + + result = await action.execute({STATE_KEY_USER_INPUT: "hello"}) + + assert result[STATE_KEY_LAST_RESPONSE] == "child response" + assert result[STATE_KEY_NODE_RESPONSES] == {"node-1": "child response"} + assert result[STATE_KEY_USER_INPUT] == "" + + emitted = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted) == 1 + assert emitted[0].get_text() == "child response" + + async def test_execute_output_mapper_paths(self): + """Output mapper should support dict/None and reject invalid return types.""" + child_event = Event( + invocation_id="inv-1", + author="child", + content=Content(role="model", parts=[Part.from_text(text="mapped")]), + partial=False, + ) + scripted_agent = _ScriptedAgent("child", [[child_event], [child_event], [child_event]]) + session = _session_with_events() + ctx = _FakeInvocationContext(scripted_agent, session) + + config_none = NodeConfig(name="node-1") + action_none, _ = _build_action( + scripted_agent, + config_none, + ctx=ctx, + output_mapper=lambda parent, child: None, + ) + assert await action_none.execute({}) == {} + + config_dict = NodeConfig(name="node-1") + action_dict, _ = _build_action( + scripted_agent, + config_dict, + ctx=ctx, + output_mapper=lambda parent, child: {"mapped": child.last_response}, + ) + mapped = await action_dict.execute({}) + assert mapped["mapped"] == "mapped" + assert mapped[STATE_KEY_USER_INPUT] == "" + + config_bad = NodeConfig(name="node-1") + action_bad, _ = _build_action( + scripted_agent, + config_bad, + ctx=ctx, + output_mapper=lambda parent, child: "bad", + ) + with pytest.raises(TypeError, match="must return dict"): + await action_bad.execute({}) + + async def test_execute_handles_transfer_to_target_agent(self): + """Transfer events should route to target agent without leaking transfer signal.""" + transfer_event = Event( + invocation_id="inv-1", + author="current", + content=Content(role="model", parts=[Part.from_text(text="handoff")]), + partial=False, + actions=EventActions(transfer_to_agent="target", state_delta={STATE_KEY_LAST_RESPONSE: "handoff"}), + ) + final_event = Event( + invocation_id="inv-1", + author="target", + content=Content(role="model", parts=[Part.from_text(text="final")]), + partial=False, + actions=EventActions(state_delta={STATE_KEY_NODE_RESPONSES: { + "target": "final" + }}), + ) + current = _ScriptedAgent("current", [[transfer_event]]) + target = _ScriptedAgent("target", [[final_event]]) + root = _RootAgent("root", {"target": target}) + current.root_agent = root + target.root_agent = root + target.parent_agent = root + + ctx = _FakeInvocationContext(current, _session_with_events(), branch="root") + config = NodeConfig(name="node-1") + action, sink = _build_action(current, config, ctx=ctx) + + result = await action.execute({}) + + assert result[STATE_KEY_LAST_RESPONSE] == "final" + emitted = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert emitted[0].actions.transfer_to_agent is None + assert target.calls[0].branch == "root.target" + + async def test_execute_handles_missing_transfer_target_with_error_event(self): + """Unknown transfer target should produce transfer_target_not_found event.""" + transfer_event = Event( + invocation_id="inv-1", + author="current", + content=Content(role="model", parts=[Part.from_text(text="handoff")]), + partial=False, + actions=EventActions(transfer_to_agent="missing"), + ) + current = _ScriptedAgent("current", [[transfer_event]]) + current.root_agent = _RootAgent("root", {}) + + ctx = _FakeInvocationContext(current, _session_with_events(), branch="root") + action, sink = _build_action(current, NodeConfig(name="node-1"), ctx=ctx) + + await action.execute({}) + + emitted = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert any(event.error_code == "transfer_target_not_found" for event in emitted) + + async def test_execute_rejects_invisible_transfer_events(self): + """Invisible transfer requests are invalid and should fail execution.""" + bad_event = Event( + invocation_id="inv-1", + author="current", + content=Content(role="model", parts=[Part.from_text(text="hidden")]), + partial=False, + visible=False, + actions=EventActions(transfer_to_agent="target"), + ) + current = _ScriptedAgent("current", [[bad_event]]) + ctx = _FakeInvocationContext(current, _session_with_events(), branch="root") + action, _ = _build_action(current, NodeConfig(name="node-1"), ctx=ctx) + + with pytest.raises(RuntimeError, match="invisible is not allowed"): + await action.execute({}) + + async def test_execute_runs_agent_event_callbacks(self): + """Agent-event callbacks should run as part of execute() event processing.""" + callback_hits: list[str] = [] + callbacks = NodeCallbacks() + + async def on_agent_event(ctx, state, event): + del state, event + callback_hits.append(ctx.node_id) + + callbacks.register_agent_event(on_agent_event) + + child_event = Event( + invocation_id="inv-1", + author="child", + content=Content(role="model", parts=[Part.from_text(text="event")]), + partial=False, + ) + scripted_agent = _ScriptedAgent("child", [[child_event]]) + ctx = _FakeInvocationContext(scripted_agent, _session_with_events()) + action, _ = _build_action( + scripted_agent, + NodeConfig(name="node-1"), + ctx=ctx, + callbacks=callbacks, + ) + + await action.execute({}) + + assert callback_hits == ["node-1"] + + async def test_execute_builds_child_history_respecting_isolated_messages(self): + """execute() should forward parent history unless isolated_messages is enabled.""" + existing = Event( + invocation_id="inv-1", + author="user", + content=Content(role="user", parts=[Part.from_text(text="history")]), + ) + child_event = Event( + invocation_id="inv-1", + author="child", + content=Content(role="model", parts=[Part.from_text(text="done")]), + partial=False, + ) + + copied_agent = _ScriptedAgent("child", [[child_event]]) + copied_ctx = _FakeInvocationContext(copied_agent, _session_with_events(existing), branch="root") + copied_action, _ = _build_action(copied_agent, NodeConfig(name="node-1"), ctx=copied_ctx) + + await copied_action.execute({STATE_KEY_USER_INPUT: "next"}) + copied_texts = [event.get_text() for event in copied_agent.calls[0].session.events if event.content] + assert copied_texts[:2] == ["history", "next"] + + isolated_agent = _ScriptedAgent("child", [[child_event]]) + isolated_ctx = _FakeInvocationContext(isolated_agent, _session_with_events(existing), branch="root") + isolated_action, _ = _build_action( + isolated_agent, + NodeConfig(name="node-1"), + ctx=isolated_ctx, + isolated_messages=True, + ) + + await isolated_action.execute({STATE_KEY_USER_INPUT: "next"}) + isolated_texts = [event.get_text() for event in isolated_agent.calls[0].session.events if event.content] + assert isolated_texts[:1] == ["next"] + + async def test_execute_ignores_graph_events_for_state_accumulation(self): + """Graph lifecycle events should not override state-derived response values.""" + graph_event = Event( + invocation_id="inv-1", + author="child", + object="graph.state.update", + content=Content(role="model", parts=[Part.from_text(text="graph")]), + partial=False, + actions=EventActions(state_delta={STATE_KEY_LAST_RESPONSE: "ignore-me"}), + ) + normal_event = Event( + invocation_id="inv-1", + author="child", + content=Content(role="model", parts=[Part.from_text(text="final")]), + partial=False, + actions=EventActions(state_delta={STATE_KEY_LAST_RESPONSE: "final"}), + ) + scripted_agent = _ScriptedAgent("child", [[graph_event, normal_event]]) + ctx = _FakeInvocationContext(scripted_agent, _session_with_events(), branch="root") + action, _ = _build_action(scripted_agent, NodeConfig(name="node-1"), ctx=ctx) + + result = await action.execute({}) + + assert result[STATE_KEY_LAST_RESPONSE] == "final" diff --git a/tests/trpc_agent_dsl/graph/test_node_action_code.py b/tests/trpc_agent_dsl/graph/test_node_action_code.py new file mode 100644 index 000000000..5e67179a1 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_action_code.py @@ -0,0 +1,93 @@ +# 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. +"""Execution-path tests for CodeNodeAction.""" + +from types import SimpleNamespace +from typing import Any + +import pytest +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._node_action._code import CodeNodeAction + + +def _build_action(executor: Any, *, ctx: Any = None) -> CodeNodeAction: + """Create CodeNodeAction with concrete event writer instances.""" + writer = EventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="code-node", + branch="root.code-node", + ) + async_writer = AsyncEventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="code-node", + branch="root.code-node", + ) + return CodeNodeAction( + name="code-node", + code_executor=executor, + code="print(42)", + language="python", + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + + +class _ScriptedExecutor: + """Executor stub that records inputs and returns configured output.""" + + def __init__(self, output: str | None): + self.output = output + self.calls: list[tuple[Any, Any]] = [] + + async def execute_code(self, ctx: Any, execution_input: Any) -> Any: + self.calls.append((ctx, execution_input)) + return SimpleNamespace(output=self.output) + + +class TestCodeNodeActionExecute: + """Tests for code-node execution and state mapping.""" + + async def test_execute_requires_invocation_context(self): + """Code node should fail fast when invocation context is missing.""" + action = _build_action(_ScriptedExecutor(output="ignored"), ctx=None) + + with pytest.raises(RuntimeError, match="requires InvocationContext"): + await action.execute({}) + + async def test_execute_maps_executor_output_and_input_fields(self): + """Code executor output should be stored in last_response and node_responses.""" + executor = _ScriptedExecutor(output="42") + ctx = SimpleNamespace(session=SimpleNamespace(id="session-1")) + action = _build_action(executor, ctx=ctx) + + result = await action.execute({"unused": True}) + + assert result[STATE_KEY_LAST_RESPONSE] == "42" + assert result[STATE_KEY_NODE_RESPONSES] == {"code-node": "42"} + assert len(executor.calls) == 1 + called_ctx, execution_input = executor.calls[0] + assert called_ctx is ctx + assert execution_input.execution_id == "session-1" + assert len(execution_input.code_blocks) == 1 + assert execution_input.code_blocks[0].language == "python" + assert execution_input.code_blocks[0].code == "print(42)" + + async def test_execute_converts_none_output_to_empty_string(self): + """None code output should normalize to an empty response string.""" + executor = _ScriptedExecutor(output=None) + ctx = SimpleNamespace(session=SimpleNamespace(id="session-2")) + action = _build_action(executor, ctx=ctx) + + result = await action.execute({}) + + assert result[STATE_KEY_LAST_RESPONSE] == "" + assert result[STATE_KEY_NODE_RESPONSES] == {"code-node": ""} diff --git a/tests/trpc_agent_dsl/graph/test_node_action_knowledge.py b/tests/trpc_agent_dsl/graph/test_node_action_knowledge.py new file mode 100644 index 000000000..c2cdd52d0 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_action_knowledge.py @@ -0,0 +1,137 @@ +# 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. +"""Execution-path tests for KnowledgeNodeAction.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._node_action._knowledge import KnowledgeNodeAction + + +def _build_action(query: Any, tool: Any, *, ctx: Any = None) -> KnowledgeNodeAction: + """Create KnowledgeNodeAction with concrete event writers.""" + writer = EventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="knowledge-node", + branch="root.knowledge-node", + ) + async_writer = AsyncEventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="knowledge-node", + branch="root.knowledge-node", + ) + return KnowledgeNodeAction( + name="knowledge-node", + query=query, + tool=tool, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + + +class TestKnowledgeNodeActionExecute: + """Tests for knowledge-node query resolution and output mapping.""" + + async def test_execute_requires_invocation_context(self): + """Knowledge node should fail when invocation context is missing.""" + tool = SimpleNamespace(run_async=AsyncMock(return_value={"documents": []})) + action = _build_action("query", tool, ctx=None) + + with pytest.raises(RuntimeError, match="requires InvocationContext"): + await action.execute({}) + + async def test_execute_normalizes_mixed_document_formats(self): + """execute() should normalize mixed document payloads and preserve message.""" + ctx = SimpleNamespace(session=SimpleNamespace(id="session-1")) + tool = SimpleNamespace( + run_async=AsyncMock(return_value={ + "documents": [ + { + "text": "doc-plain", + "score": 0.9 + }, + { + "text": "doc-with-meta", + "score": 0.8, + "metadata": { + "source": "kb-1" + } + }, + { + "document": { + "page_content": "doc-from-document", + "metadata": { + "id": 7 + } + }, + "score": 0.7 + }, + { + "document": "not-a-dict", + "score": 0.6 + }, + "ignored-item", + ], + "message": "query finished", + })) + action = _build_action(lambda state: state["topic_id"], tool, ctx=ctx) + + result = await action.execute({"topic_id": 123}) + payload = result[STATE_KEY_LAST_RESPONSE] + + assert payload["message"] == "query finished" + assert payload["documents"] == [ + { + "text": "doc-plain", + "score": 0.9 + }, + { + "text": "doc-with-meta", + "score": 0.8, + "metadata": { + "source": "kb-1" + } + }, + { + "text": "doc-from-document", + "score": 0.7, + "metadata": { + "id": 7 + } + }, + { + "text": "", + "score": 0.6 + }, + ] + assert result[STATE_KEY_NODE_RESPONSES] == {"knowledge-node": payload} + tool.run_async.assert_awaited_once_with(tool_context=ctx, args={"query": "123"}) + + async def test_execute_handles_non_dict_or_invalid_documents_payload(self): + """Invalid tool payload shapes should normalize to empty documents without message.""" + ctx = SimpleNamespace(session=SimpleNamespace(id="session-2")) + + tool_raw = SimpleNamespace(run_async=AsyncMock(return_value="invalid")) + action_raw = _build_action("query text", tool_raw, ctx=ctx) + result_raw = await action_raw.execute({}) + assert result_raw[STATE_KEY_LAST_RESPONSE] == {"documents": []} + + tool_bad_docs = SimpleNamespace(run_async=AsyncMock(return_value={ + "documents": "not-a-list", + "message": 999, + })) + action_bad_docs = _build_action("query text", tool_bad_docs, ctx=ctx) + result_bad_docs = await action_bad_docs.execute({}) + assert result_bad_docs[STATE_KEY_LAST_RESPONSE] == {"documents": []} diff --git a/tests/trpc_agent_dsl/graph/test_node_action_llm.py b/tests/trpc_agent_dsl/graph/test_node_action_llm.py new file mode 100644 index 000000000..0d8ba8c81 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_action_llm.py @@ -0,0 +1,518 @@ +# 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 LLMNodeAction behavior and execution paths.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +import pytest +from google.genai.types import Content +from google.genai.types import FunctionCall +from google.genai.types import FunctionResponse +from google.genai.types import Part +from trpc_agent_sdk.dsl.graph._constants import ROLE_USER +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE_ID +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_TOOL_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_ONE_SHOT_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._node_action._llm import LLMNodeAction +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import GenerateContentConfig + + +class _AckingWriter: + """Captures writer payloads and resolves async ack futures.""" + + def __init__(self): + self.payloads: list[dict] = [] + + def __call__(self, payload: dict) -> None: + self.payloads.append(payload) + ack = payload.get(STREAM_KEY_ACK) + if ack is not None and not ack.done(): + ack.set_result(True) + + +class _StreamingModel: + """Model stub that replays scripted streaming responses.""" + + def __init__(self, responses: list[LlmResponse], error: Exception | None = None): + self.name = "mock-model" + self._responses = responses + self._error = error + self.requests: list[tuple] = [] + + async def generate_async(self, request, *, stream: bool, ctx): + self.requests.append((request, stream, ctx)) + if self._error is not None: + raise self._error + for response in self._responses: + yield response + + +class _ToolLoopModel: + """Model stub that can emit tool calls and final text responses.""" + + def __init__(self, mode: str): + self.name = "mock-model" + self.mode = mode + self.requests: list[tuple[Any, bool, Any]] = [] + + async def generate_async(self, request: Any, *, stream: bool, ctx: Any): + self.requests.append((request, stream, ctx)) + has_tool_response = self._has_tool_response(request.contents) + + if self.mode == "single_call": + if not has_tool_response: + yield LlmResponse( + content=Content( + role="model", + parts=[Part(function_call=FunctionCall(id="call-1", name="adder", args={ + "a": 1, + "b": 2 + }))], + ), + partial=False, + response_id="resp-1", + ) + return + yield LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="done")]), + partial=False, + response_id="resp-2", + ) + return + + if self.mode == "multi_call": + if not has_tool_response: + yield LlmResponse( + content=Content( + role="model", + parts=[ + Part(function_call=FunctionCall(id="call-1", name="adder", args={ + "a": 1, + "b": 2 + })), + Part(function_call=FunctionCall(id="call-2", name="adder", args={ + "a": 3, + "b": 4 + })), + ], + ), + partial=False, + response_id="resp-1", + ) + return + yield LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="complete")]), + partial=False, + response_id="resp-2", + ) + return + + if self.mode == "always_call": + call_id = f"call-{len(self.requests)}" + yield LlmResponse( + content=Content( + role="model", + parts=[Part(function_call=FunctionCall(id=call_id, name="adder", args={ + "a": 1, + "b": 2 + }))], + ), + partial=False, + response_id=f"resp-{len(self.requests)}", + ) + return + + raise ValueError(f"Unsupported mode: {self.mode}") + + @staticmethod + def _has_tool_response(contents: list[Content]) -> bool: + for content in contents: + if not content.parts: + continue + for part in content.parts: + if part.function_response is not None: + return True + return False + + +_DEFAULT_TOOL_CTX = object() + + +def _create_tool_context() -> Any: + """Build a minimal tool_context object compatible with BaseTool.run_async.""" + return SimpleNamespace( + agent_context=None, + agent=SimpleNamespace( + before_tool_callback=None, + after_tool_callback=None, + parallel_tool_calls=False, + ), + ) + + +def _build_execute_action(model: _StreamingModel, generation_config: GenerateContentConfig | None = None, tools=None): + """Create LLMNodeAction with concrete event writers for integration tests.""" + sink = _AckingWriter() + writer = EventWriter( + writer=sink, + invocation_id="inv-1", + author="llm-node", + branch="root.llm-node", + ) + async_writer = AsyncEventWriter( + writer=sink, + invocation_id="inv-1", + author="llm-node", + branch="root.llm-node", + ) + action = LLMNodeAction( + name="llm-node", + model=model, + instruction="be helpful", + tools=tools or {}, + generation_config=generation_config, + writer=writer, + async_writer=async_writer, + ctx="ctx", + ) + return action, sink + + +def _build_tool_loop_action( + model: _ToolLoopModel, + *, + tool_parallel: bool = False, + max_tool_iterations: int = 8, + ctx: Any = _DEFAULT_TOOL_CTX, +) -> tuple[LLMNodeAction, _AckingWriter, list[tuple[Any, int, int]]]: + """Create LLMNodeAction for integrated model->tool->model loop tests.""" + sink = _AckingWriter() + writer = EventWriter( + writer=sink, + invocation_id="inv-1", + author="llm-node", + branch="root.llm-node", + ) + async_writer = AsyncEventWriter( + writer=sink, + invocation_id="inv-1", + author="llm-node", + branch="root.llm-node", + ) + calls: list[tuple[Any, int, int]] = [] + + async def adder(a: int, b: int, tool_context: Any) -> dict[str, int]: + calls.append((tool_context, a, b)) + return {"sum": a + b} + + action_ctx = _create_tool_context() if ctx is _DEFAULT_TOOL_CTX else ctx + + action = LLMNodeAction( + name="llm-node", + model=model, + instruction="be helpful", + tools={"adder": FunctionTool(adder)}, + tool_parallel=tool_parallel, + max_tool_iterations=max_tool_iterations, + generation_config=None, + writer=writer, + async_writer=async_writer, + ctx=action_ctx, + ) + return action, sink, calls + + +class TestLLMNodeActionExecute: + """Tests for full execute flow.""" + + async def test_execute_converts_foreign_tool_messages_in_request_history(self): + """execute() should convert unknown tool history to text before model call.""" + model = _StreamingModel([ + LlmResponse(content=Content(role="model", parts=[Part.from_text(text="ok")]), partial=False), + ]) + action, _ = _build_execute_action(model, tools={"known_tool": object()}) + + foreign_call = Content( + role="model", + parts=[Part(function_call=FunctionCall(id="fc-1", name="legacy_tool", args={"q": "abc"}))], + ) + foreign_response = Content( + role="user", + parts=[Part(function_response=FunctionResponse(id="fc-1", name="legacy_tool", response={"ok": True}))], + ) + known_call = Content( + role="model", + parts=[Part(function_call=FunctionCall(id="fc-2", name="known_tool", args={"x": 1}))], + ) + messages = [foreign_call, foreign_response, known_call] + + with patch("trpc_agent_sdk.dsl.graph._node_action._llm.LlmRequest.append_tools"): + await action.execute({STATE_KEY_MESSAGES: messages}) + request = model.requests[0][0] + + assert request.contents[0].parts[0].function_call is None + assert request.contents[0].parts[0].text.startswith("[Tool Call: legacy_tool(") + assert request.contents[1].parts[0].function_response is None + assert request.contents[1].parts[0].text.startswith("[Tool Response (legacy_tool):") + assert request.contents[2].parts[0].function_call is not None + assert request.contents[2].parts[0].function_call.name == "known_tool" + # Original messages remain unchanged. + assert messages[0].parts[0].function_call is not None + + async def test_execute_keeps_tool_parts_when_tools_are_available(self): + """Known tool calls should remain structured in the request history.""" + model = _StreamingModel([ + LlmResponse(content=Content(role="model", parts=[Part.from_text(text="ok")]), partial=False), + ]) + action, _ = _build_execute_action(model, tools={"known_tool": object()}) + messages = [ + Content( + role="model", + parts=[Part(function_call=FunctionCall(id="fc-1", name="known_tool", args={"x": 1}))], + ) + ] + + with patch("trpc_agent_sdk.dsl.graph._node_action._llm.LlmRequest.append_tools"): + await action.execute({STATE_KEY_MESSAGES: messages}) + request = model.requests[0][0] + + assert request.contents[0].parts[0].function_call is not None + assert request.contents[0].parts[0].function_call.name == "known_tool" + + async def test_execute_user_input_stage_streams_and_updates_state(self): + """User-input stage should append user message, stream partial text, and clear user_input.""" + model = _StreamingModel([ + LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="hel")]), + partial=True, + ), + LlmResponse( + content=Content(role="model", parts=[Part.from_text(text="hello")]), + partial=False, + response_id="resp-1", + ), + ]) + action, sink = _build_execute_action(model) + state = { + STATE_KEY_MESSAGES: [Content(role="model", parts=[Part.from_text(text="history")])], + STATE_KEY_USER_INPUT: "question", + } + + result = await action.execute(state) + + assert result[STATE_KEY_LAST_RESPONSE] == "hello" + assert result[STATE_KEY_LAST_RESPONSE_ID] == "resp-1" + assert result[STATE_KEY_NODE_RESPONSES]["llm-node"] == "hello" + assert result[STATE_KEY_USER_INPUT] == "" + assert len(result[STATE_KEY_MESSAGES]) == 2 + assert result[STATE_KEY_MESSAGES][0].role == ROLE_USER + + request, stream, ctx = model.requests[0] + assert stream is True + assert ctx == "ctx" + assert request.contents[-1].role == ROLE_USER + assert request.config.system_instruction == "be helpful" + + streamed_texts = [ + payload[STREAM_KEY_EVENT].get_text() for payload in sink.payloads + if STREAM_KEY_EVENT in payload and payload[STREAM_KEY_EVENT].partial + ] + assert "hel" in streamed_texts + + async def test_execute_skips_duplicate_user_input_when_history_already_has_latest_user_message(self): + """Duplicate user_input text should not be re-added to model request history.""" + model = _StreamingModel( + [LlmResponse(content=Content(role="model", parts=[Part.from_text(text="answer")]), partial=False)]) + action, _ = _build_execute_action(model) + user_message = Content(role=ROLE_USER, parts=[Part.from_text(text="same")]) + state = { + STATE_KEY_MESSAGES: [user_message], + STATE_KEY_USER_INPUT: "same", + } + + result = await action.execute(state) + + assert len(result[STATE_KEY_MESSAGES]) == 1 + request = model.requests[0][0] + assert request.contents == [user_message] + + async def test_execute_consumes_one_shot_messages_from_node_and_global_state(self): + """One-shot stages should clear consumed keys from returned state delta.""" + response = LlmResponse(content=Content(role="model", parts=[Part.from_text(text="done")]), partial=False) + + node_model = _StreamingModel([response]) + node_action, _ = _build_execute_action(node_model) + node_message = Content(role=ROLE_USER, parts=[Part.from_text(text="node once")]) + node_state = { + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE: { + "llm-node": [node_message] + }, + } + node_result = await node_action.execute(node_state) + + assert STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE in node_result + assert node_result[STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE] == {} + + global_model = _StreamingModel([response]) + global_action, _ = _build_execute_action(global_model) + global_message = Content(role=ROLE_USER, parts=[Part.from_text(text="global once")]) + global_state = { + STATE_KEY_ONE_SHOT_MESSAGES: [global_message], + } + global_result = await global_action.execute(global_state) + + assert global_result[STATE_KEY_ONE_SHOT_MESSAGES] == [] + + async def test_execute_keeps_function_call_parts_in_final_model_message(self): + """Final responses containing function_call parts should preserve structured parts.""" + function_call = FunctionCall(id="fc-1", name="tool", args={"a": 1}) + model = _StreamingModel([ + LlmResponse( + content=Content( + role="model", + parts=[ + Part.from_text(text="result"), + Part(function_call=function_call), + ], + ), + partial=False, + ) + ]) + action, _ = _build_execute_action(model) + + result = await action.execute({STATE_KEY_MESSAGES: []}) + + final_parts = result[STATE_KEY_MESSAGES][0].parts + assert any(part.function_call for part in final_parts) + assert any(part.text == "result" for part in final_parts) + + async def test_execute_copies_generation_config_and_appends_tools(self): + """Configured tools should be appended and generation config should remain unmutated.""" + model = _StreamingModel( + [LlmResponse(content=Content(role="model", parts=[Part.from_text(text="ok")]), partial=False)]) + generation_config = GenerateContentConfig() + tools = {"tool-a": object()} + action, _ = _build_execute_action(model, generation_config=generation_config, tools=tools) + + with patch("trpc_agent_sdk.dsl.graph._node_action._llm.LlmRequest.append_tools") as append_tools: + await action.execute({STATE_KEY_MESSAGES: []}) + + append_tools.assert_called_once() + # system instruction should be applied to copied config, not the original object. + assert generation_config.system_instruction is None + + async def test_execute_emits_model_error_and_raises_runtime_error(self): + """Model errors should be transformed to RuntimeError and emit model_complete(error).""" + model = _StreamingModel([], error=ValueError("model boom")) + action, sink = _build_execute_action(model) + + with pytest.raises(RuntimeError, match="LLM node 'llm-node' failed: model boom"): + await action.execute({STATE_KEY_MESSAGES: []}) + + texts = [payload[STREAM_KEY_EVENT].get_text() for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert any("failed" in text for text in texts) + + +class TestLLMNodeActionToolLoop: + """Tests for integrated llm_node tool execution flow.""" + + async def test_execute_runs_tool_loop_and_updates_state(self): + """llm_node should call tool, append function_response, then produce final answer.""" + model = _ToolLoopModel("single_call") + tool_ctx = _create_tool_context() + action, _, calls = _build_tool_loop_action(model, ctx=tool_ctx) + + result = await action.execute({STATE_KEY_MESSAGES: []}) + + assert len(model.requests) == 2 + assert len(calls) == 1 + assert calls[0] == (tool_ctx, 1, 2) + + assert result[STATE_KEY_LAST_RESPONSE] == "done" + assert result[STATE_KEY_NODE_RESPONSES]["llm-node"] == "done" + assert result[STATE_KEY_LAST_TOOL_RESPONSE] == '{"sum": 3}' + + messages = result[STATE_KEY_MESSAGES] + assert messages[0].parts[0].function_call is not None + assert messages[1].parts[0].function_response is not None + assert messages[2].parts[0].text == "done" + + async def test_execute_emits_function_call_and_function_response_events(self): + """Tool loop should emit visible function call/response events for observers.""" + model = _ToolLoopModel("single_call") + action, sink, _ = _build_tool_loop_action(model) + + await action.execute({STATE_KEY_MESSAGES: []}) + + emitted_calls: list[FunctionCall] = [] + emitted_responses: list[FunctionResponse] = [] + for payload in sink.payloads: + event = payload.get(STREAM_KEY_EVENT) + if event is None or event.content is None or not event.content.parts: + continue + for part in event.content.parts: + if part.function_call is not None: + emitted_calls.append(part.function_call) + if part.function_response is not None: + emitted_responses.append(part.function_response) + + assert len(emitted_calls) == 1 + assert len(emitted_responses) == 1 + assert emitted_calls[0].id == "call-1" + assert emitted_responses[0].id == "call-1" + assert emitted_calls[0].name == "adder" + assert emitted_responses[0].name == "adder" + + async def test_execute_supports_parallel_tool_calls(self): + """tool_parallel should execute multiple function calls in one round.""" + model = _ToolLoopModel("multi_call") + action, _, calls = _build_tool_loop_action(model, tool_parallel=True) + + result = await action.execute({STATE_KEY_MESSAGES: []}) + + assert len(model.requests) == 2 + assert len(calls) == 2 + responses = [] + for message in result[STATE_KEY_MESSAGES]: + if message.parts and message.parts[0].function_response is not None: + responses.append(message.parts[0].function_response.response) + + assert {"sum": 3} in responses + assert {"sum": 7} in responses + assert result[STATE_KEY_LAST_TOOL_RESPONSE] == '{"sum": 7}' + + async def test_execute_honors_max_tool_iterations(self): + """Tool loop should stop when max_tool_iterations is reached.""" + model = _ToolLoopModel("always_call") + action, _, calls = _build_tool_loop_action(model, max_tool_iterations=1) + + result = await action.execute({STATE_KEY_MESSAGES: []}) + + assert len(model.requests) == 2 + assert len(calls) == 1 + assert result[STATE_KEY_LAST_TOOL_RESPONSE] == '{"sum": 3}' + assert result[STATE_KEY_LAST_RESPONSE] == "" + + async def test_execute_requires_invocation_context_when_running_tools(self): + """Tool execution should fail when InvocationContext is missing.""" + model = _ToolLoopModel("single_call") + action, _, _ = _build_tool_loop_action(model, ctx=None) + + with pytest.raises(RuntimeError, match="requires InvocationContext"): + await action.execute({STATE_KEY_MESSAGES: []}) diff --git a/tests/trpc_agent_dsl/graph/test_node_action_mcp.py b/tests/trpc_agent_dsl/graph/test_node_action_mcp.py new file mode 100644 index 000000000..5abdee9a0 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_action_mcp.py @@ -0,0 +1,247 @@ +# 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. +"""Execution-path tests for MCPNodeAction.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter +from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from trpc_agent_sdk.dsl.graph._node_action._mcp import MCPNodeAction + + +def _build_action( + mcp_toolset: Any, + selected_tool_name: str = "search", + req_src_node: str = "prev-node", + *, + ctx: Any = None, +) -> MCPNodeAction: + """Create MCPNodeAction with concrete event writer instances.""" + writer = EventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="mcp-node", + branch="root.mcp-node", + ) + async_writer = AsyncEventWriter( + writer=lambda payload: None, + invocation_id="inv-1", + author="mcp-node", + branch="root.mcp-node", + ) + return MCPNodeAction( + name="mcp-node", + mcp_toolset=mcp_toolset, + selected_tool_name=selected_tool_name, + req_src_node=req_src_node, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + + +def _make_toolset(tools: list[Any], close: Any = None) -> SimpleNamespace: + """Build a stub MCPToolset with configurable tools and close behavior.""" + return SimpleNamespace( + get_tools=AsyncMock(return_value=tools), + close=close or AsyncMock(), + ) + + +def _make_tool(name: str, response: Any) -> SimpleNamespace: + """Build a stub BaseTool that returns a scripted response.""" + return SimpleNamespace(name=name, run_async=AsyncMock(return_value=response)) + + +class TestMCPNodeActionInit: + """Tests for MCPNodeAction constructor.""" + + def test_strips_whitespace_from_tool_and_node_names(self): + toolset = _make_toolset([]) + action = _build_action(toolset, selected_tool_name=" search ", req_src_node=" prev ") + assert action.selected_tool_name == "search" + assert action.req_src_node == "prev" + + +class TestTryParseJson: + """Tests for MCPNodeAction._try_parse_json static method.""" + + def test_returns_non_string_values_as_is(self): + assert MCPNodeAction._try_parse_json(42) == 42 + assert MCPNodeAction._try_parse_json([1, 2]) == [1, 2] + assert MCPNodeAction._try_parse_json(None) is None + + def test_parses_valid_json_string(self): + assert MCPNodeAction._try_parse_json('{"key": "value"}') == {"key": "value"} + assert MCPNodeAction._try_parse_json("[1, 2, 3]") == [1, 2, 3] + assert MCPNodeAction._try_parse_json('"hello"') == "hello" + + def test_returns_original_for_invalid_json(self): + assert MCPNodeAction._try_parse_json("not json") == "not json" + assert MCPNodeAction._try_parse_json("{bad}") == "{bad}" + + def test_returns_empty_string_as_is(self): + assert MCPNodeAction._try_parse_json("") == "" + + def test_parses_whitespace_padded_json(self): + assert MCPNodeAction._try_parse_json(' {"a": 1} ') == {"a": 1} + + def test_returns_whitespace_only_string_as_is(self): + assert MCPNodeAction._try_parse_json(" ") == " " + + +class TestResolveRequestArgs: + """Tests for MCPNodeAction._resolve_request_args.""" + + def test_extracts_args_from_node_responses(self): + toolset = _make_toolset([]) + action = _build_action(toolset, req_src_node="prev-node") + state = {"node_responses": {"prev-node": {"query": "test"}}} + assert action._resolve_request_args(state) == {"query": "test"} + + def test_raises_when_node_responses_is_not_dict(self): + toolset = _make_toolset([]) + action = _build_action(toolset, req_src_node="prev-node") + state = {"node_responses": "invalid"} + with pytest.raises(ValueError, match="to be a dict"): + action._resolve_request_args(state) + + def test_raises_when_source_node_missing_from_responses(self): + toolset = _make_toolset([]) + action = _build_action(toolset, req_src_node="missing-node") + state = {"node_responses": {"other-node": {}}} + with pytest.raises(ValueError, match="missing"): + action._resolve_request_args(state) + + def test_raises_when_source_node_payload_is_not_dict(self): + toolset = _make_toolset([]) + action = _build_action(toolset, req_src_node="prev-node") + state = {"node_responses": {"prev-node": "not-a-dict"}} + with pytest.raises(ValueError, match="to be a dict"): + action._resolve_request_args(state) + + +class TestResolveSelectedTool: + """Tests for MCPNodeAction._resolve_selected_tool.""" + + async def test_returns_matching_tool(self): + tool = _make_tool("search", "result") + toolset = _make_toolset([_make_tool("other", "x"), tool]) + action = _build_action(toolset, selected_tool_name="search") + ctx = SimpleNamespace() + result = await action._resolve_selected_tool(ctx) + assert result is tool + + async def test_raises_when_tool_not_found(self): + toolset = _make_toolset([_make_tool("other", "x")]) + action = _build_action(toolset, selected_tool_name="missing") + ctx = SimpleNamespace() + with pytest.raises(ValueError, match="cannot find selected tool"): + await action._resolve_selected_tool(ctx) + + async def test_raises_when_no_tools_available(self): + toolset = _make_toolset([]) + action = _build_action(toolset, selected_tool_name="search") + ctx = SimpleNamespace() + with pytest.raises(ValueError, match="cannot find selected tool"): + await action._resolve_selected_tool(ctx) + + +class TestMCPNodeActionExecute: + """Tests for MCPNodeAction.execute end-to-end flow.""" + + async def test_execute_success_with_json_response(self): + """Execute should resolve tool, call it, parse JSON, and map state.""" + tool = _make_tool("search", '{"results": [1, 2]}') + toolset = _make_toolset([tool]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="search", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {"query": "test"}}} + result = await action.execute(state) + + assert result[STATE_KEY_LAST_RESPONSE] == '{"results": [1, 2]}' + assert result[STATE_KEY_NODE_RESPONSES] == {"mcp-node": {"results": [1, 2]}} + tool.run_async.assert_awaited_once_with(tool_context=ctx, args={"query": "test"}) + toolset.close.assert_awaited_once() + + async def test_execute_success_with_non_json_response(self): + """Non-JSON string responses should be stored as-is in node_responses.""" + tool = _make_tool("greet", "Hello world") + toolset = _make_toolset([tool]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="greet", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {"name": "user"}}} + result = await action.execute(state) + + assert result[STATE_KEY_LAST_RESPONSE] == "Hello world" + assert result[STATE_KEY_NODE_RESPONSES] == {"mcp-node": "Hello world"} + + async def test_execute_success_with_dict_response(self): + """Dict responses bypass JSON parsing and are stored directly.""" + raw_dict = {"key": "value", "count": 5} + tool = _make_tool("api", raw_dict) + toolset = _make_toolset([tool]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="api", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {"param": "x"}}} + result = await action.execute(state) + + assert result[STATE_KEY_LAST_RESPONSE] == raw_dict + assert result[STATE_KEY_NODE_RESPONSES] == {"mcp-node": raw_dict} + + async def test_execute_closes_toolset_after_call(self): + """Toolset.close() must be called even on success.""" + tool = _make_tool("search", "ok") + toolset = _make_toolset([tool]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="search", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {}}} + await action.execute(state) + + toolset.close.assert_awaited_once() + + async def test_execute_propagates_tool_runtime_error(self): + """MCP tool errors should propagate without being swallowed.""" + tool = SimpleNamespace( + name="search", + run_async=AsyncMock(side_effect=RuntimeError("connection refused")), + ) + toolset = _make_toolset([tool]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="search", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {"q": "test"}}} + with pytest.raises(RuntimeError, match="connection refused"): + await action.execute(state) + + async def test_execute_fails_when_tool_not_found(self): + """Execute should fail if selected_tool_name doesn't match any tool.""" + toolset = _make_toolset([_make_tool("other", "x")]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="missing", req_src_node="prev-node", ctx=ctx) + + state = {"node_responses": {"prev-node": {}}} + with pytest.raises(ValueError, match="cannot find selected tool"): + await action.execute(state) + + async def test_execute_fails_when_request_args_invalid(self): + """Execute should fail fast when source node payload is invalid.""" + toolset = _make_toolset([_make_tool("search", "ok")]) + ctx = SimpleNamespace() + action = _build_action(toolset, selected_tool_name="search", req_src_node="missing", ctx=ctx) + + state = {"node_responses": {}} + with pytest.raises(ValueError, match="missing"): + await action.execute(state) diff --git a/tests/trpc_agent_dsl/graph/test_node_config.py b/tests/trpc_agent_dsl/graph/test_node_config.py new file mode 100644 index 000000000..ee8088462 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_node_config.py @@ -0,0 +1,62 @@ +# 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 common NodeConfig behavior.""" + +from trpc_agent_sdk.dsl.graph._node_config import NodeConfig + + +class TestNodeConfigCommonFields: + """Tests for common config fields shared by all node types.""" + + def test_config_preserves_common_fields(self): + """NodeConfig should preserve name/description/metadata.""" + config = NodeConfig( + name="worker", + description="Does work", + metadata={"owner": "team-a"}, + ) + + assert config.name == "worker" + assert config.description == "Does work" + assert config.metadata == {"owner": "team-a"} + + +class TestNodeConfigMetadata: + """Tests for metadata serialization logic.""" + + def test_to_metadata_merges_custom_metadata_and_sets_node_type(self): + """to_metadata should include common fields and explicit node type.""" + config = NodeConfig( + name="Classifier", + description="Classifies intent", + metadata={ + "owner": "team-a", + "version": "v2", + }, + ) + + metadata = config.to_metadata(node_type="llm") + + assert metadata["name"] == "Classifier" + assert metadata["description"] == "Classifies intent" + assert metadata["owner"] == "team-a" + assert metadata["version"] == "v2" + assert metadata["node_type"] == "llm" + + def test_to_metadata_node_type_overrides_custom_node_type_key(self): + """Runtime node type should win even if metadata contains node_type.""" + config = NodeConfig( + name="ToolExec", + metadata={ + "node_type": "custom", + "x": 1, + }, + ) + + metadata = config.to_metadata(node_type="tool") + + assert metadata["node_type"] == "tool" + assert metadata["x"] == 1 diff --git a/tests/trpc_agent_dsl/graph/test_state.py b/tests/trpc_agent_dsl/graph/test_state.py new file mode 100644 index 000000000..92080c513 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_state.py @@ -0,0 +1,157 @@ +# 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 graph state reducers and helpers.""" + +from google.genai.types import Content +from google.genai.types import Part +from trpc_agent_sdk.dsl.graph._constants import METADATA_KEY_INVOCATION_ID +from trpc_agent_sdk.dsl.graph._constants import METADATA_KEY_SESSION_ID +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE_ID +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_TOOL_RESPONSE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_METADATA +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_ONE_SHOT_MESSAGES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_SESSION +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_STEP_NUMBER +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT +from trpc_agent_sdk.dsl.graph._state import StateUtils +from trpc_agent_sdk.dsl.graph._state import append_list +from trpc_agent_sdk.dsl.graph._state import merge_dict +from trpc_agent_sdk.dsl.graph._state import messages_reducer + + +class TestStateReducers: + """Tests for reducer behavior.""" + + def test_messages_reducer_appends_single_or_list_messages(self): + """Reducer should support both single Content and list inputs.""" + first = Content(role="user", parts=[Part.from_text(text="hello")]) + second = Content(role="model", parts=[Part.from_text(text="world")]) + + with_single = messages_reducer([first], second) + with_list = messages_reducer([first], [second]) + + assert with_single == [first, second] + assert with_list == [first, second] + + def test_merge_dict_and_append_list_handle_none_and_mixed_input(self): + """Base reducers should gracefully handle None and scalar/list updates.""" + assert merge_dict(None, {"a": 1}) == {"a": 1} + assert merge_dict({"a": 1}, None) == {"a": 1} + assert merge_dict({"a": 1}, {"a": 2, "b": 3}) == {"a": 2, "b": 3} + + assert append_list(None, None) == [] + assert append_list([1], 2) == [1, 2] + assert append_list([1], [2, 3]) == [1, 2, 3] + + +class TestStateUtilsOneShot: + """Tests for one-shot message consume flow.""" + + def test_consume_one_shot_messages_combines_and_clears_consumed_entries(self): + """Global and node-scoped one-shot messages should be consumed together.""" + global_msg = Content(role="user", parts=[Part.from_text(text="global")]) + node_msg = Content(role="user", parts=[Part.from_text(text="node")]) + other_node_msg = Content(role="user", parts=[Part.from_text(text="other")]) + + state = { + STATE_KEY_ONE_SHOT_MESSAGES: [global_msg], + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE: { + "node_a": [node_msg], + "node_b": [other_node_msg], + }, + } + + consumed, state_update = StateUtils.consume_one_shot_messages(state, "node_a") + + assert consumed == [global_msg, node_msg] + assert state_update[STATE_KEY_ONE_SHOT_MESSAGES] == [] + assert state_update[STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE] == {"node_b": [other_node_msg]} + + def test_consume_one_shot_messages_returns_empty_update_when_nothing_to_clear(self): + """No one-shot content should produce no state mutation.""" + consumed, state_update = StateUtils.consume_one_shot_messages({}, "node_a") + + assert consumed == [] + assert state_update == {} + + +class TestStateUtilsClone: + """Tests for deep clone helper.""" + + def test_clone_creates_deep_copy(self): + """Mutating nested objects in clone must not affect source state.""" + source = { + "nested": { + "items": [1, 2], + } + } + + cloned = StateUtils.clone(source) + cloned["nested"]["items"].append(3) + + assert source["nested"]["items"] == [1, 2] + assert cloned["nested"]["items"] == [1, 2, 3] + + +class TestStateUtilsGetters: + """Tests for state getter convenience methods.""" + + def test_getters_cover_present_and_default_paths(self): + """Getter APIs should return values when present and sensible defaults otherwise.""" + message = Content(role="user", parts=[Part.from_text(text="hello")]) + session = object() + state = { + STATE_KEY_USER_INPUT: "input", + STATE_KEY_LAST_RESPONSE: "response", + STATE_KEY_LAST_RESPONSE_ID: "resp-1", + STATE_KEY_LAST_TOOL_RESPONSE: "tool-out", + STATE_KEY_NODE_RESPONSES: { + "n1": "v1" + }, + STATE_KEY_METADATA: { + METADATA_KEY_INVOCATION_ID: "inv-1", + METADATA_KEY_SESSION_ID: "sess-1", + }, + STATE_KEY_MESSAGES: [message], + STATE_KEY_SESSION: session, + STATE_KEY_ONE_SHOT_MESSAGES: [message], + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE: { + "n1": [message] + }, + STATE_KEY_STEP_NUMBER: 9, + } + + assert StateUtils.get_user_input(state) == "input" + assert StateUtils.get_last_response(state) == "response" + assert StateUtils.get_last_response_id(state) == "resp-1" + assert StateUtils.get_last_tool_response(state) == "tool-out" + assert StateUtils.get_node_response(state, "n1") == "v1" + assert StateUtils.get_metadata(state)[METADATA_KEY_INVOCATION_ID] == "inv-1" + assert StateUtils.get_invocation_id(state) == "inv-1" + assert StateUtils.get_session_id(state) == "sess-1" + assert StateUtils.get_messages(state) == [message] + assert StateUtils.get_session(state) is session + assert StateUtils.get_one_shot_messages(state) == [message] + assert StateUtils.get_one_shot_messages_for_node(state, "n1") == [message] + assert StateUtils.get_step_number(state) == 9 + + empty: dict = {} + assert StateUtils.get_user_input(empty) == "" + assert StateUtils.get_last_response(empty) == "" + assert StateUtils.get_last_response_id(empty) == "" + assert StateUtils.get_last_tool_response(empty) == "" + assert StateUtils.get_node_response(empty, "missing") is None + assert StateUtils.get_invocation_id(empty) == "" + assert StateUtils.get_session_id(empty) == "" + assert StateUtils.get_messages(empty) == [] + assert StateUtils.get_session(empty) is None + assert StateUtils.get_one_shot_messages(empty) == [] + assert StateUtils.get_one_shot_messages_for_node(empty, "n1") == [] + assert StateUtils.get_step_number(empty) == 0 diff --git a/tests/trpc_agent_dsl/graph/test_state_graph.py b/tests/trpc_agent_dsl/graph/test_state_graph.py new file mode 100644 index 000000000..331f70e93 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_state_graph.py @@ -0,0 +1,606 @@ +# 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. +"""Public-API tests for StateGraph and CompiledStateGraph.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock +from unittest.mock import patch + +import pytest +from langgraph.errors import GraphInterrupt +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbacks +from trpc_agent_sdk.dsl.graph._constants import END +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_METADATA +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_STEP_NUMBER +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK +from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._memory_saver import MemorySaverOption +from trpc_agent_sdk.dsl.graph._node_config import NodeConfig +from trpc_agent_sdk.dsl.graph._state import State +from trpc_agent_sdk.dsl.graph._state_graph import CompiledStateGraph +from trpc_agent_sdk.dsl.graph._state_graph import StateGraph + + +class _AckingWriter: + """Captures stream payloads and resolves async acknowledgements.""" + + def __init__(self): + self.payloads: list[dict[str, Any]] = [] + + def __call__(self, payload: dict[str, Any]) -> None: + self.payloads.append(payload) + ack = payload.get(STREAM_KEY_ACK) + if ack is not None and not ack.done(): + ack.set_result(True) + + +def _capture_added_wrapper(graph: StateGraph, register_node) -> tuple[Any, dict[str, Any]]: + """Capture wrapped node callable registered on underlying LangGraph object.""" + captured: dict[str, Any] = {} + + def fake_add_node(name, wrapper, **kwargs): + captured["name"] = name + captured["wrapper"] = wrapper + captured["kwargs"] = kwargs + + with patch.object(graph._graph, "add_node", side_effect=fake_add_node): + register_node() + + return captured["wrapper"], captured["kwargs"] + + +class TestStateGraphValidation: + """Tests for basic StateGraph validation paths.""" + + def test_add_node_rejects_non_async_action(self): + """Graph nodes should enforce async actions to match runtime contract.""" + graph = StateGraph(State) + + def sync_action(state): + del state + return {} + + with pytest.raises(TypeError, match="must be async"): + graph.add_node("sync", sync_action) + + def test_add_agent_node_rejects_none_agent(self): + """Agent node registration should fail fast for missing agent references.""" + graph = StateGraph(State) + + with pytest.raises(TypeError, match="must not be None"): + graph.add_agent_node("sub", agent=None) + + def test_add_node_sets_default_config_name(self): + """NodeConfig name should be normalized through public add_node + compile path.""" + graph = StateGraph(State) + config = NodeConfig(name=None) + + async def action(state): + del state + return {} + + graph.add_node("worker", action, config=config) + graph.set_entry_point("worker") + graph.set_finish_point("worker") + compiled = graph.compile() + stored = compiled.get_node_config("worker") + + assert stored is not None + assert stored.name == "worker" + assert config.name == "worker" + + +class TestStateGraphBuilders: + """Tests for node-builder convenience methods and compile wrapper.""" + + async def test_add_llm_and_agent_node_builders_create_callable_actions(self): + """Builder helpers should wrap corresponding NodeAction execute methods.""" + graph = StateGraph(State) + model = SimpleNamespace(name="demo-model") + tools = {"tool_a": object()} + sub_agent = SimpleNamespace(name="sub-agent") + + captured_actions: dict[str, callable] = {} + captured_configs: dict[str, NodeConfig] = {} + captured_node_types: dict[str, str] = {} + + def fake_add_node(name, action, *, config=None, callbacks=None, _node_type="function"): + del callbacks + captured_actions[name] = action + captured_configs[name] = config + captured_node_types[name] = _node_type + return graph + + with patch.object(StateGraph, "add_node", side_effect=fake_add_node), patch( + "trpc_agent_sdk.dsl.graph._state_graph.LLMNodeAction") as llm_action_cls, patch( + "trpc_agent_sdk.dsl.graph._state_graph.AgentNodeAction") as agent_action_cls: + llm_instance = llm_action_cls.return_value + llm_instance.execute = AsyncMock(return_value={"llm": True}) + agent_instance = agent_action_cls.return_value + agent_instance.execute = AsyncMock(return_value={"agent": True}) + + llm_config = NodeConfig(name="llm-node") + graph.add_llm_node( + "llm", + model, + "instruction", + tools=tools, + tool_parallel=True, + max_tool_iterations=3, + config=llm_config, + ) + agent_config = NodeConfig(name="agent-node") + graph.add_agent_node( + "agent", + sub_agent, + config=agent_config, + input_mapper=lambda state: {"in": state.get("x")}, + output_mapper=lambda parent, child: {"out": child.last_response}, + ) + + assert captured_node_types["llm"] == "llm" + assert captured_node_types["agent"] == "agent" + + writer = object() + async_writer = object() + state = {"x": 1} + + llm_result = await captured_actions["llm"](state, writer, async_writer, ctx=None) + agent_result = await captured_actions["agent"]( + state, + writer, + async_writer, + ctx=None, + callback_ctx=None, + callbacks=None, + ) + + assert llm_result == {"llm": True} + assert agent_result == {"agent": True} + assert llm_action_cls.call_args is not None + assert llm_action_cls.call_args.args[3] == tools + assert llm_action_cls.call_args.kwargs["tool_parallel"] is True + assert llm_action_cls.call_args.kwargs["max_tool_iterations"] == 3 + assert agent_action_cls.call_args is not None + assert callable(agent_action_cls.call_args.kwargs["input_mapper"]) + assert callable(agent_action_cls.call_args.kwargs["output_mapper"]) + + def test_add_llm_node_rejects_non_positive_max_tool_iterations(self): + """max_tool_iterations should reject non-positive values.""" + graph = StateGraph(State) + model = SimpleNamespace(name="demo-model") + + with pytest.raises(ValueError, match="greater than 0"): + graph.add_llm_node( + "llm", + model, + "instruction", + max_tool_iterations=0, + ) + + def test_graph_helpers_are_chainable_and_compile_returns_wrapper(self): + """Public graph helper methods should be chainable and compile should succeed.""" + graph = StateGraph(State) + + async def action(state): + del state + return {} + + graph.add_node("node", action) + + assert graph.set_entry_point("node") is graph + assert graph.set_finish_point("node") is graph + + branch_graph = StateGraph(State) + branch_graph.add_node("router", action) + route = lambda state: END # noqa: E731 + assert branch_graph.add_conditional_edges("router", route, {END: END}) is branch_graph + + compiled = graph.compile(memory_saver_option=MemorySaverOption(auto_persist=True, persist_writes=True)) + assert isinstance(compiled, CompiledStateGraph) + assert compiled.source is graph + + async def test_compiled_state_graph_streams_and_exposes_node_configs(self): + """CompiledStateGraph should proxy stream output and node config lookup.""" + + class _FakeCompiled: + + async def astream(self, graph_input, config, *, stream_mode): + del graph_input, config, stream_mode + yield ("updates", {"node": {"x": 1}}) + yield ("custom", {"event": "payload"}) + + source = StateGraph(State) + + async def action(state): + del state + return {} + + source.add_node("node", action) + compiled = CompiledStateGraph(_FakeCompiled(), source) + + items = [] + async for item in compiled.astream({}, {"configurable": {}}, stream_mode=["updates", "custom"]): + items.append(item) + + assert items[0][0] == "updates" + assert items[1][0] == "custom" + node_config = compiled.get_node_config("node") + assert node_config is not None + assert node_config.name == "node" + assert compiled.get_node_config("missing") is None + + +class TestStateGraphWrapperExecution: + """Tests for add_node runtime wrapper behavior.""" + + async def test_wrapper_injects_dependencies_and_runs_callbacks(self): + """Wrapper should inject dependencies and apply callbacks in merged order.""" + callback_hits: list[str] = [] + + global_callbacks = NodeCallbacks() + node_callbacks = NodeCallbacks() + + async def global_before(ctx, state): + del state + callback_hits.append(f"before:{ctx.step_number}") + return None + + async def node_after(ctx, state, result, error): + del ctx, state, error + callback_hits.append(f"node_after:{result['payload']}") + modified = dict(result) + modified["payload"] = "node-modified" + return modified + + async def global_after(ctx, state, result, error): + del ctx, state, error + callback_hits.append(f"global_after:{result['payload']}") + return None + + global_callbacks.register_before_node(global_before) + global_callbacks.register_after_node(global_after) + node_callbacks.register_after_node(node_after) + + graph = StateGraph(State, callbacks=global_callbacks) + captured_args: dict[str, Any] = {} + + async def action(state, writer, async_writer, ctx, callback_ctx, callbacks): + captured_args["writer"] = writer + captured_args["async_writer"] = async_writer + captured_args["ctx"] = ctx + captured_args["callback_ctx"] = callback_ctx + captured_args["callbacks"] = callbacks + assert state["input"] == "value" + return {"payload": "raw"} + + wrapper, _ = _capture_added_wrapper( + graph, + lambda: graph.add_node("worker", action, callbacks=node_callbacks), + ) + + sink = _AckingWriter() + invocation_ctx = SimpleNamespace(name="ctx") + state = { + STATE_KEY_METADATA: { + "invocation_id": "inv-1", + "branch": "root.worker", + "session_id": "session-1", + }, + STATE_KEY_STEP_NUMBER: 3, + "input": "value", + } + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": { + "invocation_context": invocation_ctx + }}): + result = await wrapper(state) + + assert result["payload"] == "node-modified" + assert result[STATE_KEY_STEP_NUMBER] == 4 + assert captured_args["ctx"] is invocation_ctx + assert captured_args["callback_ctx"].step_number == 3 + assert captured_args["writer"].author == "worker" + assert captured_args["async_writer"].author == "worker" + assert captured_args["callbacks"] is not None + assert callback_hits == [ + "before:3", + "node_after:raw", + "global_after:node-modified", + ] + emitted_events = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted_events) == 2 + + async def test_before_callback_can_short_circuit_execution(self): + """Before callback non-None return should bypass node action and event emission.""" + callbacks = NodeCallbacks() + action_called = False + + async def before_cb(ctx, state): + del ctx, state + return {"short_circuit": True} + + callbacks.register_before_node(before_cb) + + graph = StateGraph(State, callbacks=callbacks) + + async def action(state): + del state + nonlocal action_called + action_called = True + return {"unexpected": True} + + wrapper, _ = _capture_added_wrapper(graph, lambda: graph.add_node("worker", action)) + sink = _AckingWriter() + + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": {}}): + result = await wrapper({"input": "value"}) + + assert result == {"short_circuit": True} + assert action_called is False + assert sink.payloads == [] + + async def test_wrapper_converts_none_return_to_empty_update(self): + """None return should be normalized to empty update and still increment step number.""" + graph = StateGraph(State) + + async def action(state): + del state + return None + + wrapper, _ = _capture_added_wrapper(graph, lambda: graph.add_node("worker", action)) + sink = _AckingWriter() + + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": {}}): + result = await wrapper({STATE_KEY_STEP_NUMBER: 1}) + + assert result == {STATE_KEY_STEP_NUMBER: 2} + emitted_events = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted_events) == 2 + + async def test_wrapper_raises_when_ctx_is_required_but_missing(self): + """Missing invocation context should raise and trigger node-error callbacks.""" + callback_errors: list[str] = [] + callbacks = NodeCallbacks() + + async def on_error(ctx, state, error): + del state + callback_errors.append(f"{ctx.node_id}:{str(error)}") + + callbacks.register_on_error(on_error) + graph = StateGraph(State) + + async def action(state, ctx): + del state, ctx + return {} + + wrapper, _ = _capture_added_wrapper(graph, lambda: graph.add_node("worker", action, callbacks=callbacks)) + sink = _AckingWriter() + + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": {}}): + with pytest.raises(RuntimeError, match="requires InvocationContext"): + await wrapper({STATE_KEY_STEP_NUMBER: 2}) + + emitted_events = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted_events) == 2 + assert callback_errors + assert callback_errors[0].startswith("worker:") + + async def test_wrapper_rejects_non_dict_returns(self): + """Node wrapper should fail fast when action returns unsupported type.""" + graph = StateGraph(State) + + async def action(state): + del state + return "bad" + + wrapper, _ = _capture_added_wrapper(graph, lambda: graph.add_node("worker", action)) + sink = _AckingWriter() + + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": {}}): + with pytest.raises(TypeError, match="must return a dict or None"): + await wrapper({}) + + emitted_events = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted_events) == 2 + + async def test_wrapper_propagates_graph_interrupt_without_node_error(self): + """GraphInterrupt should propagate as control flow and skip node-error event.""" + graph = StateGraph(State) + + async def action(state): + del state + raise GraphInterrupt() + + wrapper, _ = _capture_added_wrapper(graph, lambda: graph.add_node("worker", action)) + sink = _AckingWriter() + + with patch("trpc_agent_sdk.dsl.graph._state_graph.get_stream_writer", return_value=sink), patch( + "trpc_agent_sdk.dsl.graph._state_graph.get_config", + return_value={"configurable": {}}): + with pytest.raises(GraphInterrupt): + await wrapper({}) + + emitted_events = [payload[STREAM_KEY_EVENT] for payload in sink.payloads if STREAM_KEY_EVENT in payload] + assert len(emitted_events) == 1 + + +class TestStateGraphBuilderCoverage: + """Additional tests for builder helper coverage and validation.""" + + def test_add_node_without_metadata_falls_back_to_plain_registration(self): + """When metadata adapter returns empty dict, graph node registration omits metadata arg.""" + graph = StateGraph(State) + captured_kwargs: dict[str, Any] = {} + + async def action(state): + del state + return {} + + def fake_add_node(name, wrapper, **kwargs): + del name, wrapper + captured_kwargs.update(kwargs) + + config = NodeConfig(name="worker") + with patch.object(NodeConfig, "to_metadata", return_value={}): + with patch.object(graph._graph, "add_node", side_effect=fake_add_node): + graph.add_node("worker", action, config=config) + + assert captured_kwargs == {} + + async def test_add_code_knowledge_and_mcp_builders_delegate_to_actions(self): + """Helper builders should construct matching NodeAction classes and execute their actions.""" + graph = StateGraph(State) + captured_actions: dict[str, Any] = {} + captured_configs: dict[str, NodeConfig | None] = {} + captured_types: dict[str, str] = {} + + def fake_add_node(name, action, *, config=None, callbacks=None, _node_type="function"): + del callbacks + captured_actions[name] = action + captured_configs[name] = config + captured_types[name] = _node_type + return graph + + with patch.object(StateGraph, "add_node", side_effect=fake_add_node), patch( + "trpc_agent_sdk.dsl.graph._state_graph.CodeNodeAction") as code_action_cls, patch( + "trpc_agent_sdk.dsl.graph._state_graph.KnowledgeNodeAction") as knowledge_action_cls, patch( + "trpc_agent_sdk.dsl.graph._state_graph.MCPNodeAction") as mcp_action_cls: + code_action_cls.return_value.execute = AsyncMock(return_value={"code": True}) + knowledge_action_cls.return_value.execute = AsyncMock(return_value={"knowledge": True}) + mcp_action_cls.return_value.execute = AsyncMock(return_value={"mcp": True}) + + graph.add_code_node("code", code_executor=SimpleNamespace(), code="print(1)", language="python") + knowledge_config = NodeConfig(name=None) + graph.add_knowledge_node("knowledge", query="who", tool=SimpleNamespace(), config=knowledge_config) + graph.add_mcp_node( + "mcp", + mcp_toolset=SimpleNamespace(), + selected_tool_name=" weather ", + req_src_node=" req_builder ", + ) + + writer = object() + async_writer = object() + state = {"input": 1} + code_result = await captured_actions["code"](state, writer, async_writer, ctx=None) + knowledge_result = await captured_actions["knowledge"](state, writer, async_writer, ctx=None) + mcp_result = await captured_actions["mcp"](state, writer, async_writer, ctx=None) + + assert captured_types["code"] == "code" + assert captured_types["knowledge"] == "knowledge" + assert captured_types["mcp"] == "tool" + assert code_result == {"code": True} + assert knowledge_result == {"knowledge": True} + assert mcp_result == {"mcp": True} + assert knowledge_config.name == "knowledge" + assert captured_configs["knowledge"] is knowledge_config + assert code_action_cls.call_args is not None + assert code_action_cls.call_args.kwargs["code"] == "print(1)" + assert knowledge_action_cls.call_args is not None + assert knowledge_action_cls.call_args.kwargs["query"] == "who" + assert mcp_action_cls.call_args is not None + assert mcp_action_cls.call_args.kwargs["selected_tool_name"] == "weather" + assert mcp_action_cls.call_args.kwargs["req_src_node"] == "req_builder" + + def test_add_knowledge_node_validates_required_tool(self): + """Knowledge node should reject missing tool references.""" + graph = StateGraph(State) + + with pytest.raises(TypeError, match="must not be None"): + graph.add_knowledge_node("knowledge", query="who", tool=None) + + def test_add_mcp_node_validates_required_fields(self): + """MCP builder should validate toolset and required identifiers.""" + graph = StateGraph(State) + + with pytest.raises(TypeError, match="must not be None"): + graph.add_mcp_node("mcp", mcp_toolset=None, selected_tool_name="weather", req_src_node="req") + + with pytest.raises(ValueError, match="selected_tool_name"): + graph.add_mcp_node("mcp", mcp_toolset=SimpleNamespace(), selected_tool_name=" ", req_src_node="req") + + with pytest.raises(ValueError, match="req_src_node"): + graph.add_mcp_node("mcp", mcp_toolset=SimpleNamespace(), selected_tool_name="weather", req_src_node=" ") + + def test_add_llm_node_normalizes_missing_config_name(self): + """LLM builder should set default config names when config is missing or nameless.""" + graph = StateGraph(State) + model = SimpleNamespace(name="demo-model") + captured_configs: dict[str, NodeConfig] = {} + + def fake_add_node(name, action, *, config=None, callbacks=None, _node_type="function"): + del action, callbacks, _node_type + captured_configs[name] = config + return graph + + with patch.object(StateGraph, "add_node", side_effect=fake_add_node): + graph.add_llm_node("llm_default", model, "instruction") + nameless = NodeConfig(name=None) + graph.add_llm_node("llm_nameless", model, "instruction", config=nameless) + + assert captured_configs["llm_default"].name == "llm_default" + assert captured_configs["llm_nameless"].name == "llm_nameless" + + def test_add_knowledge_and_mcp_normalize_missing_config_name(self): + """Knowledge/MCP builders should assign node names when config is omitted or nameless.""" + graph = StateGraph(State) + captured_configs: dict[str, NodeConfig | None] = {} + + def fake_add_node(name, action, *, config=None, callbacks=None, _node_type="function"): + del action, callbacks, _node_type + captured_configs[name] = config + return graph + + with patch.object(StateGraph, "add_node", side_effect=fake_add_node): + graph.add_knowledge_node("knowledge_default", query="query", tool=SimpleNamespace()) + nameless_config = NodeConfig(name=None) + graph.add_mcp_node( + "mcp_nameless", + mcp_toolset=SimpleNamespace(), + selected_tool_name="tool", + req_src_node="req", + config=nameless_config, + ) + + assert captured_configs["knowledge_default"] is not None + assert captured_configs["knowledge_default"].name == "knowledge_default" + assert captured_configs["mcp_nameless"] is nameless_config + assert nameless_config.name == "mcp_nameless" + + def test_add_agent_node_tracks_agents_and_returns_copy(self): + """add_agent_node should normalize config names and expose copied agent mapping.""" + graph = StateGraph(State) + captured_configs: dict[str, NodeConfig] = {} + + def fake_add_node(name, action, *, config=None, callbacks=None, _node_type="function"): + del action, callbacks, _node_type + captured_configs[name] = config + return graph + + agent_default = SimpleNamespace(name="a-default") + agent_named = SimpleNamespace(name="a-named") + with patch.object(StateGraph, "add_node", side_effect=fake_add_node): + graph.add_agent_node("agent_default", agent_default) + nameless = NodeConfig(name=None) + graph.add_agent_node("agent_nameless", agent_named, config=nameless) + + assert captured_configs["agent_default"].name == "agent_default" + assert captured_configs["agent_nameless"].name == "agent_nameless" + assert graph.agent_nodes["agent_default"] is agent_default + copied = graph.agent_nodes + copied["agent_default"] = SimpleNamespace(name="mutated") + assert graph.agent_nodes["agent_default"] is agent_default diff --git a/tests/trpc_agent_dsl/graph/test_state_mapper.py b/tests/trpc_agent_dsl/graph/test_state_mapper.py new file mode 100644 index 000000000..2e6f0a8d0 --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_state_mapper.py @@ -0,0 +1,92 @@ +# 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 state mapping utilities.""" + +from trpc_agent_sdk.dsl.graph._state_mapper import StateMapper +from trpc_agent_sdk.dsl.graph._state_mapper import SubgraphResult + + +class TestStateMapperInput: + """Tests for input mapping behavior.""" + + def test_combine_merges_outputs_and_last_mapper_wins(self): + """Combined mapper should merge outputs in declaration order.""" + state = { + "query": "hello", + "context": "world", + "count": 2, + } + + mapper = StateMapper.combine( + StateMapper.pick("query", "context"), + lambda current: { + "query": current["query"].upper(), + "count": current["count"] + 1, + }, + ) + + assert mapper(state) == { + "query": "HELLO", + "context": "world", + "count": 3, + } + + def test_rename_ignores_missing_source_fields(self): + """Rename mapper should only emit keys that exist in source state.""" + mapper = StateMapper.rename({ + "source": "target", + "missing": "ignored", + }) + + assert mapper({"source": "value"}) == {"target": "value"} + + def test_identity_returns_a_new_dict(self): + """Identity mapper should not return the same mapping object.""" + source = {"value": 1} + mapped = StateMapper.identity()(source) + + assert mapped == source + assert mapped is not source + + mapped["value"] = 2 + assert source["value"] == 1 + + def test_filter_and_exclude_select_expected_keys(self): + """Key filtering and exclusion should preserve only intended fields.""" + state = { + "user_id": "u-1", + "user_name": "alice", + "internal_secret": "token", + "trace_id": "t-1", + } + + only_user_fields = StateMapper.filter_keys(lambda key: key.startswith("user_"))(state) + redacted = StateMapper.exclude("internal_secret")(state) + + assert only_user_fields == { + "user_id": "u-1", + "user_name": "alice", + } + assert redacted == { + "user_id": "u-1", + "user_name": "alice", + "trace_id": "t-1", + } + + +class TestStateMapperOutput: + """Tests for output mapping behavior.""" + + def test_merge_response_maps_child_last_response(self): + """Output mapper should project child last response into target field.""" + mapper = StateMapper.merge_response("research_result") + parent_state = {"request_id": "r-1"} + child_result = SubgraphResult(last_response="final answer") + + mapped = mapper(parent_state, child_result) + + assert mapped == {"research_result": "final answer"} + assert parent_state == {"request_id": "r-1"} diff --git a/tests/types/__init__.py b/tests/types/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/tests/types/__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 Apache-2.0. diff --git a/tests/types/test_agent_types.py b/tests/types/test_agent_types.py new file mode 100644 index 000000000..6c06f5ac7 --- /dev/null +++ b/tests/types/test_agent_types.py @@ -0,0 +1,164 @@ +# 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. +"""Tests for trpc_agent_sdk.types._agent_types. + +Covers: + - LiveRequest: construction, defaults, serialisation config + - LiveRequestQueue: close, send_content, send_realtime, send, async get + - ActiveStreamingTool: construction, extra-forbid, arbitrary types +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest +from google.genai.types import Blob, Content, Part +from pydantic import ValidationError + +from trpc_agent_sdk.types._agent_types import ( + ActiveStreamingTool, + LiveRequest, + LiveRequestQueue, +) + + +# --------------------------------------------------------------------------- +# LiveRequest +# --------------------------------------------------------------------------- +class TestLiveRequest: + """Tests for the LiveRequest Pydantic model.""" + + def test_default_values(self): + req = LiveRequest() + assert req.content is None + assert req.blob is None + assert req.close is False + + def test_close_flag(self): + req = LiveRequest(close=True) + assert req.close is True + + def test_with_content(self): + content = Content(parts=[Part(text="hello")]) + req = LiveRequest(content=content) + assert req.content is not None + assert req.content.parts[0].text == "hello" + + def test_with_blob(self): + blob = Blob(data=b"raw", mime_type="audio/pcm") + req = LiveRequest(blob=blob) + assert req.blob is not None + assert req.blob.mime_type == "audio/pcm" + + def test_serialisation_roundtrip(self): + content = Content(parts=[Part(text="test")]) + req = LiveRequest(content=content, close=True) + json_str = req.model_dump_json() + restored = LiveRequest.model_validate_json(json_str) + assert restored.close is True + assert restored.content.parts[0].text == "test" + + def test_model_config_json_bytes(self): + cfg = LiveRequest.model_config + assert cfg.get("ser_json_bytes") == "base64" + assert cfg.get("val_json_bytes") == "base64" + + +# --------------------------------------------------------------------------- +# LiveRequestQueue +# --------------------------------------------------------------------------- +class TestLiveRequestQueue: + """Tests for the async LiveRequestQueue wrapper.""" + + def test_init_creates_queue(self): + q = LiveRequestQueue() + assert hasattr(q, "_queue") + assert isinstance(q._queue, asyncio.Queue) + + async def test_close_sends_close_request(self): + q = LiveRequestQueue() + q.close() + req = await q.get() + assert isinstance(req, LiveRequest) + assert req.close is True + + async def test_send_content(self): + q = LiveRequestQueue() + content = Content(parts=[Part(text="hi")]) + q.send_content(content) + req = await q.get() + assert req.content is not None + assert req.content.parts[0].text == "hi" + assert req.close is False + + async def test_send_realtime(self): + q = LiveRequestQueue() + blob = Blob(data=b"\x00\x01", mime_type="audio/pcm") + q.send_realtime(blob) + req = await q.get() + assert req.blob is not None + assert req.blob.mime_type == "audio/pcm" + + async def test_send_raw_request(self): + q = LiveRequestQueue() + raw = LiveRequest(close=True) + q.send(raw) + req = await q.get() + assert req is raw + + async def test_fifo_order(self): + q = LiveRequestQueue() + q.send(LiveRequest(close=False)) + q.send(LiveRequest(close=True)) + + first = await q.get() + second = await q.get() + assert first.close is False + assert second.close is True + + async def test_get_blocks_until_item_available(self): + q = LiveRequestQueue() + loop = asyncio.get_running_loop() + + async def _delayed_put(): + await asyncio.sleep(0.05) + q.send(LiveRequest(close=True)) + + loop.create_task(_delayed_put()) + req = await q.get() + assert req.close is True + + +# --------------------------------------------------------------------------- +# ActiveStreamingTool +# --------------------------------------------------------------------------- +class TestActiveStreamingTool: + """Tests for the ActiveStreamingTool Pydantic model.""" + + def test_default_values(self): + tool = ActiveStreamingTool() + assert tool.task is None + assert tool.stream is None + + def test_with_task(self): + mock_task = MagicMock(spec=asyncio.Task) + tool = ActiveStreamingTool(task=mock_task) + assert tool.task is mock_task + + def test_with_stream(self): + q = LiveRequestQueue() + tool = ActiveStreamingTool(stream=q) + assert tool.stream is q + + def test_extra_forbid(self): + with pytest.raises(ValidationError): + ActiveStreamingTool(unknown_field="nope") + + def test_arbitrary_types_allowed(self): + cfg = ActiveStreamingTool.model_config + assert cfg.get("arbitrary_types_allowed") is True diff --git a/tests/types/test_event_actions.py b/tests/types/test_event_actions.py new file mode 100644 index 000000000..6408ad31b --- /dev/null +++ b/tests/types/test_event_actions.py @@ -0,0 +1,124 @@ +# 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. +"""Tests for trpc_agent_sdk.types._event_actions. + +Covers: + - EventActions: defaults, field assignment, camelCase aliasing, + extra-forbid, populate_by_name, serialisation +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from trpc_agent_sdk.types._event_actions import EventActions + + +class TestEventActionsDefaults: + """Default values and construction.""" + + def test_default_construction(self): + ea = EventActions() + assert ea.skip_summarization is None + assert ea.state_delta == {} + assert ea.artifact_delta == {} + assert ea.transfer_to_agent is None + assert ea.escalate is None + + def test_state_delta_default_is_independent(self): + ea1 = EventActions() + ea2 = EventActions() + ea1.state_delta["key"] = "value" + assert "key" not in ea2.state_delta + + def test_artifact_delta_default_is_independent(self): + ea1 = EventActions() + ea2 = EventActions() + ea1.artifact_delta["file.txt"] = 1 + assert "file.txt" not in ea2.artifact_delta + + +class TestEventActionsFields: + """Explicit field assignment.""" + + def test_skip_summarization(self): + ea = EventActions(skip_summarization=True) + assert ea.skip_summarization is True + + def test_state_delta(self): + delta = {"counter": 5, "flag": True} + ea = EventActions(state_delta=delta) + assert ea.state_delta == delta + + def test_artifact_delta(self): + delta = {"report.pdf": 3} + ea = EventActions(artifact_delta=delta) + assert ea.artifact_delta == delta + + def test_transfer_to_agent(self): + ea = EventActions(transfer_to_agent="agent_b") + assert ea.transfer_to_agent == "agent_b" + + def test_escalate(self): + ea = EventActions(escalate=True) + assert ea.escalate is True + + +class TestEventActionsCamelAlias: + """camelCase alias generation and populate_by_name.""" + + def test_camel_alias_in_serialisation(self): + ea = EventActions(skip_summarization=True, transfer_to_agent="x") + data = ea.model_dump(by_alias=True) + assert "skipSummarization" in data + assert "transferToAgent" in data + assert "stateDelta" in data + assert "artifactDelta" in data + + def test_construction_with_camel_alias(self): + ea = EventActions(**{ + "skipSummarization": False, + "stateDelta": {"k": "v"}, + "artifactDelta": {"f": 1}, + "transferToAgent": "agent_c", + "escalate": True, + }) + assert ea.skip_summarization is False + assert ea.state_delta == {"k": "v"} + assert ea.transfer_to_agent == "agent_c" + + def test_populate_by_name(self): + ea = EventActions(skip_summarization=True) + assert ea.skip_summarization is True + + def test_json_roundtrip_by_alias(self): + ea = EventActions( + skip_summarization=True, + state_delta={"a": 1}, + artifact_delta={"b": 2}, + transfer_to_agent="target", + escalate=False, + ) + json_str = ea.model_dump_json(by_alias=True) + restored = EventActions.model_validate_json(json_str) + assert restored.skip_summarization is True + assert restored.state_delta == {"a": 1} + assert restored.artifact_delta == {"b": 2} + assert restored.transfer_to_agent == "target" + assert restored.escalate is False + + +class TestEventActionsExtraForbid: + """Extra fields should be rejected.""" + + def test_extra_field_raises(self): + with pytest.raises(ValidationError): + EventActions(unknown="bad") + + def test_extra_camel_field_raises(self): + with pytest.raises(ValidationError): + EventActions(unknownField="bad") diff --git a/tests/types/test_instruction.py b/tests/types/test_instruction.py new file mode 100644 index 000000000..6bcd2eda2 --- /dev/null +++ b/tests/types/test_instruction.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 Apache-2.0. +"""Tests for trpc_agent_sdk.types._instruction. + +Covers: + - InstructionMetadata: defaults, field population + - Instruction: construction, compile() template substitution, __call__ protocol +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from trpc_agent_sdk.types._instruction import Instruction, InstructionMetadata + + +# --------------------------------------------------------------------------- +# InstructionMetadata +# --------------------------------------------------------------------------- +class TestInstructionMetadata: + """Tests for the InstructionMetadata dataclass.""" + + def test_required_fields(self): + meta = InstructionMetadata(name="prompt_v1", version=3) + assert meta.name == "prompt_v1" + assert meta.version == 3 + + def test_default_type(self): + meta = InstructionMetadata(name="x", version=1) + assert meta.type == "text" + + def test_custom_type(self): + meta = InstructionMetadata(name="x", version=1, type="chat") + assert meta.type == "chat" + + def test_default_labels(self): + meta = InstructionMetadata(name="x", version=1) + assert meta.labels == [] + + def test_labels_populated(self): + meta = InstructionMetadata(name="x", version=1, labels=["production", "v2"]) + assert meta.labels == ["production", "v2"] + + def test_labels_default_is_independent(self): + m1 = InstructionMetadata(name="a", version=1) + m2 = InstructionMetadata(name="b", version=2) + m1.labels.append("prod") + assert "prod" not in m2.labels + + def test_default_config(self): + meta = InstructionMetadata(name="x", version=1) + assert meta.config == {} + + def test_config_populated(self): + cfg = {"temperature": 0.7, "max_tokens": 100} + meta = InstructionMetadata(name="x", version=1, config=cfg) + assert meta.config == cfg + + def test_config_default_is_independent(self): + m1 = InstructionMetadata(name="a", version=1) + m2 = InstructionMetadata(name="b", version=2) + m1.config["key"] = "val" + assert "key" not in m2.config + + +# --------------------------------------------------------------------------- +# Instruction +# --------------------------------------------------------------------------- +class TestInstruction: + """Tests for the Instruction dataclass.""" + + def _make_instruction(self, text: str = "Hello {{name}}") -> Instruction: + meta = InstructionMetadata(name="test", version=1) + return Instruction(instruction=text, metadata=meta) + + def test_construction(self): + instr = self._make_instruction("plain text") + assert instr.instruction == "plain text" + assert instr.metadata.name == "test" + + def test_compile_single_variable(self): + instr = self._make_instruction("Hello {{name}}") + result = instr.compile(name="Alice") + assert result == "Hello Alice" + + def test_compile_multiple_variables(self): + instr = self._make_instruction("{{greeting}}, {{name}}!") + result = instr.compile(greeting="Hi", name="Bob") + assert result == "Hi, Bob!" + + def test_compile_no_variables(self): + instr = self._make_instruction("No placeholders") + result = instr.compile() + assert result == "No placeholders" + + def test_compile_missing_variable_keeps_placeholder(self): + instr = self._make_instruction("Hello {{name}}") + result = instr.compile() + assert result == "Hello {{name}}" + + def test_compile_partial_variables(self): + instr = self._make_instruction("{{a}} and {{b}}") + result = instr.compile(a="X") + assert result == "X and {{b}}" + + def test_compile_does_not_mutate_original(self): + instr = self._make_instruction("{{x}}") + instr.compile(x="replaced") + assert instr.instruction == "{{x}}" + + def test_compile_repeated_placeholder(self): + instr = self._make_instruction("{{v}} + {{v}}") + result = instr.compile(v="1") + assert result == "1 + 1" + + def test_call_returns_instruction(self): + instr = self._make_instruction("system prompt") + ctx = MagicMock() + assert instr(ctx) == "system prompt" + + def test_call_ignores_ctx(self): + instr = self._make_instruction("text") + assert instr(None) == "text" + assert instr("anything") == "text" diff --git a/tests/types/test_memory.py b/tests/types/test_memory.py new file mode 100644 index 000000000..5aceaa355 --- /dev/null +++ b/tests/types/test_memory.py @@ -0,0 +1,106 @@ +# 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. +"""Tests for trpc_agent_sdk.types._memory. + +Covers: + - MemoryEntry: construction, optional fields + - SearchMemoryResponse: default factory, population, serialisation +""" + +from __future__ import annotations + +from google.genai.types import Content, Part + +from trpc_agent_sdk.types._memory import MemoryEntry, SearchMemoryResponse + + +# --------------------------------------------------------------------------- +# MemoryEntry +# --------------------------------------------------------------------------- +class TestMemoryEntry: + """Tests for the MemoryEntry Pydantic model.""" + + def _make_content(self, text: str = "hello") -> Content: + return Content(parts=[Part(text=text)]) + + def test_required_content(self): + c = self._make_content() + entry = MemoryEntry(content=c) + assert entry.content.parts[0].text == "hello" + + def test_optional_author_default(self): + entry = MemoryEntry(content=self._make_content()) + assert entry.author is None + + def test_optional_author_set(self): + entry = MemoryEntry(content=self._make_content(), author="user_1") + assert entry.author == "user_1" + + def test_optional_timestamp_default(self): + entry = MemoryEntry(content=self._make_content()) + assert entry.timestamp is None + + def test_optional_timestamp_set(self): + entry = MemoryEntry( + content=self._make_content(), + timestamp="2026-01-01T00:00:00Z", + ) + assert entry.timestamp == "2026-01-01T00:00:00Z" + + def test_full_construction(self): + entry = MemoryEntry( + content=self._make_content("data"), + author="agent", + timestamp="2026-06-15T12:00:00Z", + ) + assert entry.content.parts[0].text == "data" + assert entry.author == "agent" + assert entry.timestamp == "2026-06-15T12:00:00Z" + + def test_json_roundtrip(self): + entry = MemoryEntry( + content=self._make_content("round"), + author="bot", + timestamp="2026-03-01T00:00:00Z", + ) + json_str = entry.model_dump_json() + restored = MemoryEntry.model_validate_json(json_str) + assert restored.author == "bot" + assert restored.timestamp == "2026-03-01T00:00:00Z" + + +# --------------------------------------------------------------------------- +# SearchMemoryResponse +# --------------------------------------------------------------------------- +class TestSearchMemoryResponse: + """Tests for the SearchMemoryResponse Pydantic model.""" + + def _make_entry(self, text: str = "mem") -> MemoryEntry: + return MemoryEntry(content=Content(parts=[Part(text=text)])) + + def test_default_empty(self): + resp = SearchMemoryResponse() + assert resp.memories == [] + + def test_default_is_independent(self): + r1 = SearchMemoryResponse() + r2 = SearchMemoryResponse() + r1.memories.append(self._make_entry()) + assert len(r2.memories) == 0 + + def test_with_memories(self): + entries = [self._make_entry("a"), self._make_entry("b")] + resp = SearchMemoryResponse(memories=entries) + assert len(resp.memories) == 2 + assert resp.memories[0].content.parts[0].text == "a" + + def test_json_roundtrip(self): + entries = [self._make_entry("x")] + resp = SearchMemoryResponse(memories=entries) + json_str = resp.model_dump_json() + restored = SearchMemoryResponse.model_validate_json(json_str) + assert len(restored.memories) == 1 + assert restored.memories[0].content.parts[0].text == "x" diff --git a/tests/types/test_state.py b/tests/types/test_state.py new file mode 100644 index 000000000..e81b0fd3b --- /dev/null +++ b/tests/types/test_state.py @@ -0,0 +1,213 @@ +# 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. +"""Tests for trpc_agent_sdk.types._state. + +Covers: + - State: __getitem__, __setitem__, __contains__, + has_delta, get, update, to_dict, class prefixes +""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.types._state import State + + +class TestStatePrefixes: + """Class-level prefix constants.""" + + def test_app_prefix(self): + assert State.APP_PREFIX == "app:" + + def test_user_prefix(self): + assert State.USER_PREFIX == "user:" + + def test_temp_prefix(self): + assert State.TEMP_PREFIX == "temp:" + + +class TestStateInit: + """Construction and initial state.""" + + def test_empty(self): + s = State(value={}, delta={}) + assert s.to_dict() == {} + assert not s.has_delta() + + def test_with_value_only(self): + s = State(value={"a": 1}, delta={}) + assert s["a"] == 1 + assert not s.has_delta() + + def test_with_delta_only(self): + s = State(value={}, delta={"b": 2}) + assert s["b"] == 2 + assert s.has_delta() + + def test_with_both(self): + s = State(value={"a": 1}, delta={"b": 2}) + assert s["a"] == 1 + assert s["b"] == 2 + + +class TestStateGetItem: + """__getitem__ behaviour — delta takes precedence over value.""" + + def test_from_value(self): + s = State(value={"k": "v"}, delta={}) + assert s["k"] == "v" + + def test_from_delta(self): + s = State(value={}, delta={"k": "d"}) + assert s["k"] == "d" + + def test_delta_overrides_value(self): + s = State(value={"k": "old"}, delta={"k": "new"}) + assert s["k"] == "new" + + def test_missing_key_raises(self): + s = State(value={}, delta={}) + with pytest.raises(KeyError): + _ = s["missing"] + + +class TestStateSetItem: + """__setitem__ writes to both value and delta.""" + + def test_set_new_key(self): + s = State(value={}, delta={}) + s["x"] = 42 + assert s["x"] == 42 + assert s.has_delta() + + def test_set_overwrites_existing(self): + s = State(value={"x": 1}, delta={}) + s["x"] = 99 + assert s["x"] == 99 + + def test_set_updates_delta(self): + s = State(value={}, delta={}) + s["k"] = "v" + assert s._delta["k"] == "v" + + def test_set_updates_value(self): + s = State(value={}, delta={}) + s["k"] = "v" + assert s._value["k"] == "v" + + +class TestStateContains: + """__contains__ checks both value and delta.""" + + def test_in_value(self): + s = State(value={"a": 1}, delta={}) + assert "a" in s + + def test_in_delta(self): + s = State(value={}, delta={"b": 2}) + assert "b" in s + + def test_not_present(self): + s = State(value={}, delta={}) + assert "z" not in s + + def test_in_both(self): + s = State(value={"c": 1}, delta={"c": 2}) + assert "c" in s + + +class TestStateHasDelta: + """has_delta() reflects pending changes.""" + + def test_no_delta(self): + s = State(value={"a": 1}, delta={}) + assert not s.has_delta() + + def test_with_delta(self): + s = State(value={}, delta={"a": 1}) + assert s.has_delta() + + def test_after_set(self): + s = State(value={}, delta={}) + s["key"] = "value" + assert s.has_delta() + + +class TestStateGet: + """get() with default fallback.""" + + def test_existing_key(self): + s = State(value={"k": 10}, delta={}) + assert s.get("k") == 10 + + def test_missing_key_default_none(self): + s = State(value={}, delta={}) + assert s.get("missing") is None + + def test_missing_key_custom_default(self): + s = State(value={}, delta={}) + assert s.get("missing", 42) == 42 + + def test_delta_key(self): + s = State(value={}, delta={"d": "yes"}) + assert s.get("d") == "yes" + + def test_delta_overrides_in_get(self): + s = State(value={"k": "old"}, delta={"k": "new"}) + assert s.get("k") == "new" + + +class TestStateUpdate: + """update() merges into both value and delta.""" + + def test_update_empty(self): + s = State(value={"a": 1}, delta={}) + s.update({}) + assert s.to_dict() == {"a": 1} + assert not s.has_delta() + + def test_update_adds_keys(self): + s = State(value={}, delta={}) + s.update({"x": 1, "y": 2}) + assert s["x"] == 1 + assert s["y"] == 2 + assert s.has_delta() + + def test_update_overwrites(self): + s = State(value={"x": 0}, delta={}) + s.update({"x": 99}) + assert s["x"] == 99 + + +class TestStateToDict: + """to_dict() merges value and delta.""" + + def test_empty(self): + s = State(value={}, delta={}) + assert s.to_dict() == {} + + def test_value_only(self): + s = State(value={"a": 1, "b": 2}, delta={}) + assert s.to_dict() == {"a": 1, "b": 2} + + def test_delta_only(self): + s = State(value={}, delta={"c": 3}) + assert s.to_dict() == {"c": 3} + + def test_merged(self): + s = State(value={"a": 1}, delta={"b": 2}) + assert s.to_dict() == {"a": 1, "b": 2} + + def test_delta_overrides_value_in_dict(self): + s = State(value={"k": "old"}, delta={"k": "new"}) + assert s.to_dict()["k"] == "new" + + def test_to_dict_returns_copy(self): + s = State(value={"a": 1}, delta={}) + d = s.to_dict() + d["a"] = 999 + assert s["a"] == 1 diff --git a/tests/types/test_ttl.py b/tests/types/test_ttl.py new file mode 100644 index 000000000..20904a20d --- /dev/null +++ b/tests/types/test_ttl.py @@ -0,0 +1,242 @@ +# 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. +"""Tests for trpc_agent_sdk.types._ttl. + +Covers: + - Module-level constants: DEFAULT_TTL_SECONDS, DEFAULT_CLEANUP_INTERVAL_SECONDS + - Ttl: defaults, model_post_init, need_ttl_expire, clean_ttl_config, + update_expired_at, is_expired, is_expired_by_timestamp +""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +from trpc_agent_sdk.types._ttl import ( + DEFAULT_CLEANUP_INTERVAL_SECONDS, + DEFAULT_TTL_SECONDS, + Ttl, +) + + +# --------------------------------------------------------------------------- +# Module constants +# --------------------------------------------------------------------------- +class TestModuleConstants: + """Verify module-level default values.""" + + def test_default_ttl_seconds(self): + assert DEFAULT_TTL_SECONDS == 24 * 60 * 60 + + def test_default_cleanup_interval_seconds(self): + assert DEFAULT_CLEANUP_INTERVAL_SECONDS == 60.0 * 60.0 + + +# --------------------------------------------------------------------------- +# Ttl defaults +# --------------------------------------------------------------------------- +class TestTtlDefaults: + """Default construction and field values.""" + + def test_enable_default(self): + ttl = Ttl() + assert ttl.enable is True + + def test_ttl_seconds_default(self): + ttl = Ttl() + assert ttl.ttl_seconds == DEFAULT_TTL_SECONDS + + def test_cleanup_interval_default(self): + ttl = Ttl() + assert ttl.cleanup_interval_seconds == DEFAULT_CLEANUP_INTERVAL_SECONDS + + def test_update_time_set_by_post_init(self): + ttl = Ttl() + assert ttl.update_time > 0 + + def test_update_time_close_to_now(self): + before = time.time() + ttl = Ttl() + after = time.time() + assert before <= ttl.update_time <= after + + +# --------------------------------------------------------------------------- +# model_post_init +# --------------------------------------------------------------------------- +class TestTtlModelPostInit: + """model_post_init sets update_time correctly.""" + + def test_post_init_sets_time_when_ttl_needed(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + assert ttl.update_time > 0 + + def test_post_init_zeroes_time_when_disabled(self): + ttl = Ttl(enable=False) + assert ttl.update_time == 0.0 + + def test_post_init_zeroes_time_when_ttl_zero(self): + ttl = Ttl(enable=True, ttl_seconds=0) + assert ttl.update_time == 0.0 + + def test_post_init_zeroes_time_when_cleanup_zero(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=0.0) + assert ttl.update_time == 0.0 + + +# --------------------------------------------------------------------------- +# need_ttl_expire +# --------------------------------------------------------------------------- +class TestNeedTtlExpire: + """Logic: enable AND ttl_seconds > 0 AND cleanup_interval_seconds > 0.""" + + def test_all_conditions_met(self): + ttl = Ttl(enable=True, ttl_seconds=60, cleanup_interval_seconds=30.0) + assert ttl.need_ttl_expire() is True + + def test_disabled(self): + ttl = Ttl(enable=False, ttl_seconds=60, cleanup_interval_seconds=30.0) + assert ttl.need_ttl_expire() is False + + def test_ttl_zero(self): + ttl = Ttl(enable=True, ttl_seconds=0, cleanup_interval_seconds=30.0) + assert ttl.need_ttl_expire() is False + + def test_cleanup_zero(self): + ttl = Ttl(enable=True, ttl_seconds=60, cleanup_interval_seconds=0.0) + assert ttl.need_ttl_expire() is False + + def test_all_zero(self): + ttl = Ttl(enable=False, ttl_seconds=0, cleanup_interval_seconds=0.0) + assert ttl.need_ttl_expire() is False + + +# --------------------------------------------------------------------------- +# clean_ttl_config +# --------------------------------------------------------------------------- +class TestCleanTtlConfig: + """clean_ttl_config resets all fields.""" + + def test_clean(self): + ttl = Ttl(enable=True, ttl_seconds=300, cleanup_interval_seconds=60.0) + ttl.clean_ttl_config() + assert ttl.enable is False + assert ttl.ttl_seconds == 0 + assert ttl.cleanup_interval_seconds == 0.0 + assert ttl.update_time == 0.0 + + def test_clean_idempotent(self): + ttl = Ttl() + ttl.clean_ttl_config() + ttl.clean_ttl_config() + assert ttl.enable is False + assert ttl.ttl_seconds == 0 + + +# --------------------------------------------------------------------------- +# update_expired_at +# --------------------------------------------------------------------------- +class TestUpdateExpiredAt: + """update_expired_at refreshes update_time.""" + + def test_refreshes_time(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + old_time = ttl.update_time + + with patch("trpc_agent_sdk.types._ttl.time.time", return_value=old_time + 50): + ttl.update_expired_at() + + assert ttl.update_time == old_time + 50 + + def test_zeroes_when_not_needed(self): + ttl = Ttl(enable=False) + ttl.update_expired_at() + assert ttl.update_time == 0.0 + + def test_zeroes_when_ttl_zero(self): + ttl = Ttl(enable=True, ttl_seconds=0) + ttl.update_expired_at() + assert ttl.update_time == 0.0 + + +# --------------------------------------------------------------------------- +# is_expired +# --------------------------------------------------------------------------- +class TestIsExpired: + """is_expired checks current time against update_time + ttl_seconds.""" + + def test_not_expired_recently_created(self): + ttl = Ttl(enable=True, ttl_seconds=3600, cleanup_interval_seconds=60.0) + assert ttl.is_expired() is False + + def test_expired_with_explicit_now(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + future = ttl.update_time + 200 + assert ttl.is_expired(now=future) is True + + def test_not_expired_exact_boundary(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + boundary = ttl.update_time + 100 + assert ttl.is_expired(now=boundary) is False + + def test_just_past_boundary(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + just_past = ttl.update_time + 100.001 + assert ttl.is_expired(now=just_past) is True + + def test_disabled_never_expires(self): + ttl = Ttl(enable=False, ttl_seconds=100, cleanup_interval_seconds=10.0) + assert ttl.is_expired(now=time.time() + 999999) is False + + def test_ttl_zero_never_expires(self): + ttl = Ttl(enable=True, ttl_seconds=0, cleanup_interval_seconds=10.0) + assert ttl.is_expired(now=time.time() + 999999) is False + + def test_is_expired_uses_current_time_by_default(self): + fixed = 1000.0 + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + ttl.update_time = fixed + with patch("trpc_agent_sdk.types._ttl.time.time", return_value=fixed + 200): + assert ttl.is_expired() is True + + +# --------------------------------------------------------------------------- +# is_expired_by_timestamp +# --------------------------------------------------------------------------- +class TestIsExpiredByTimestamp: + """is_expired_by_timestamp compares an external timestamp.""" + + def test_not_expired(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + now = time.time() + assert ttl.is_expired_by_timestamp(now - 50, now=now) is False + + def test_expired(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + now = time.time() + assert ttl.is_expired_by_timestamp(now - 200, now=now) is True + + def test_exact_boundary(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + now = 2000.0 + assert ttl.is_expired_by_timestamp(now - 100, now=now) is False + + def test_just_past_boundary(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + now = 2000.0 + assert ttl.is_expired_by_timestamp(now - 100.001, now=now) is True + + def test_disabled_never_expires(self): + ttl = Ttl(enable=False, ttl_seconds=100, cleanup_interval_seconds=10.0) + assert ttl.is_expired_by_timestamp(0, now=time.time()) is False + + def test_uses_current_time_by_default(self): + ttl = Ttl(enable=True, ttl_seconds=100, cleanup_interval_seconds=10.0) + fixed_now = 5000.0 + with patch("trpc_agent_sdk.types._ttl.time.time", return_value=fixed_now): + assert ttl.is_expired_by_timestamp(fixed_now - 200) is True + assert ttl.is_expired_by_timestamp(fixed_now - 50) is False diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/tests/utils/__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 Apache-2.0. diff --git a/tests/utils/test_context_utils.py b/tests/utils/test_context_utils.py new file mode 100644 index 000000000..bad69e65e --- /dev/null +++ b/tests/utils/test_context_utils.py @@ -0,0 +1,120 @@ +# 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 trpc_agent_sdk.utils._context_utils. + +Covers: +- AsyncClosingContextManager: __aenter__, __aexit__, aclose delegation, + exception safety, isinstance check against AbstractAsyncContextManager +""" + +from contextlib import AbstractAsyncContextManager + +from trpc_agent_sdk.utils import AsyncClosingContextManager + + +class TestAsyncClosingContextManager: + """Test suite for AsyncClosingContextManager class.""" + + async def test_basic_enter_returns_generator(self): + async def gen(): + yield "value" + + g = gen() + async with AsyncClosingContextManager(g) as manager: + assert manager is g + + async def test_iterate_values(self): + async def gen(): + yield "first" + yield "second" + + g = gen() + async with AsyncClosingContextManager(g) as manager: + val = await manager.__anext__() + assert val == "first" + + async def test_closes_generator_on_exit(self): + closed = [] + + async def gen(): + try: + yield "test" + finally: + closed.append(True) + + g = gen() + async with AsyncClosingContextManager(g) as manager: + await manager.__anext__() + + assert len(closed) == 1 + + async def test_closes_generator_on_exception(self): + closed = [] + + async def gen(): + try: + yield "test" + finally: + closed.append(True) + + g = gen() + try: + async with AsyncClosingContextManager(g) as manager: + await manager.__anext__() + raise ValueError("boom") + except ValueError: + pass + + assert len(closed) == 1 + + async def test_is_abstract_async_context_manager(self): + async def gen(): + yield 1 + + cm = AsyncClosingContextManager(gen()) + assert isinstance(cm, AbstractAsyncContextManager) + + async def test_async_generator_attribute(self): + async def gen(): + yield 42 + + g = gen() + cm = AsyncClosingContextManager(g) + assert cm.async_generator is g + await g.aclose() + + async def test_multiple_yields_closed_early(self): + """Generator with many yields is properly closed even if not fully consumed.""" + steps = [] + + async def gen(): + try: + for i in range(100): + steps.append(i) + yield i + finally: + steps.append("closed") + + g = gen() + async with AsyncClosingContextManager(g) as manager: + await manager.__anext__() + await manager.__anext__() + + assert steps[-1] == "closed" + assert len(steps) < 100 + + async def test_no_iteration_still_closes(self): + """Generator is closed even if never iterated (aclose on unstarted gen).""" + async def gen(): + yield "never consumed" + + g = gen() + async with AsyncClosingContextManager(g): + pass + # After exiting, generator should be closed - iterating raises StopAsyncIteration + import pytest + with pytest.raises(StopAsyncIteration): + await g.__anext__() diff --git a/tests/utils/test_execute_cmd.py b/tests/utils/test_execute_cmd.py new file mode 100644 index 000000000..85a495fd3 --- /dev/null +++ b/tests/utils/test_execute_cmd.py @@ -0,0 +1,150 @@ +# 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 trpc_agent_sdk.utils._execute_cmd. + +Covers: +- CommandExecResult namedtuple +- async_execute_command: success, failure, timeout, input, env, exception branches +""" + +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from trpc_agent_sdk.utils import CommandExecResult +from trpc_agent_sdk.utils import async_execute_command + + +class TestCommandExecResult: + """Test suite for CommandExecResult namedtuple.""" + + def test_fields(self): + r = CommandExecResult(stdout="out", stderr="err", exit_code=0, is_timeout=False) + assert r.stdout == "out" + assert r.stderr == "err" + assert r.exit_code == 0 + assert r.is_timeout is False + + def test_is_namedtuple(self): + r = CommandExecResult("", "", 0, False) + assert r._fields == ("stdout", "stderr", "exit_code", "is_timeout") + + def test_equality(self): + r1 = CommandExecResult("a", "b", 1, True) + r2 = CommandExecResult("a", "b", 1, True) + assert r1 == r2 + + def test_index_access(self): + r = CommandExecResult("out", "err", 42, True) + assert r[0] == "out" + assert r[1] == "err" + assert r[2] == 42 + assert r[3] is True + + +class TestAsyncExecuteCommand: + """Test suite for async_execute_command function.""" + + async def test_success(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command(Path(tmpdir), ["echo", "hello world"]) + assert isinstance(result, CommandExecResult) + assert result.exit_code == 0 + assert result.is_timeout is False + assert "hello" in result.stdout + assert result.stderr == "" + + async def test_success_with_timeout(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command(Path(tmpdir), ["echo", "test"], timeout=5.0) + assert result.exit_code == 0 + assert result.is_timeout is False + assert "test" in result.stdout + + async def test_failure_nonzero_exit(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command(Path(tmpdir), ["false"]) + assert result.exit_code != 0 + assert result.is_timeout is False + assert "command failed" in result.stderr + + async def test_timeout_triggers(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command(Path(tmpdir), ["sleep", "10"], timeout=0.1) + assert result.is_timeout is True + assert result.exit_code == -1 + assert "timed out" in result.stderr.lower() or "timeout" in result.stderr.lower() + + async def test_empty_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command(Path(tmpdir), ["true"]) + assert result.exit_code == 0 + assert result.stdout == "" + assert result.stderr == "" + + async def test_multiline_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), ["sh", "-c", "echo -e 'line1\nline2\nline3'"] + ) + assert result.exit_code == 0 + assert "line1" in result.stdout + assert "line2" in result.stdout + + async def test_with_stdin_input(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), ["cat"], input=b"hello from stdin" + ) + assert result.exit_code == 0 + assert "hello from stdin" in result.stdout + + async def test_with_env(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), + ["sh", "-c", "echo $MY_TEST_VAR"], + env={"MY_TEST_VAR": "test_value", "PATH": "/usr/bin:/bin"}, + ) + assert result.exit_code == 0 + assert "test_value" in result.stdout + + async def test_nonexistent_command_exception(self): + """Trigger the generic except Exception branch (lines 78-79).""" + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), ["__nonexistent_cmd_xyz__"] + ) + assert result.exit_code == -1 + assert result.is_timeout is False + assert "command execution error" in result.stderr + + async def test_invalid_workdir_exception(self): + """Trigger exception with a non-existent working directory.""" + result = await async_execute_command( + Path("/nonexistent/directory/xyz"), ["echo", "test"] + ) + assert result.exit_code == -1 + assert result.is_timeout is False + assert "command execution error" in result.stderr + + async def test_failure_with_stderr_content(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), ["sh", "-c", "echo err_msg >&2; exit 1"] + ) + assert result.exit_code != 0 + assert "err_msg" in result.stderr + + async def test_with_timeout_and_input(self): + with tempfile.TemporaryDirectory() as tmpdir: + result = await async_execute_command( + Path(tmpdir), ["cat"], input=b"data", timeout=5.0 + ) + assert result.exit_code == 0 + assert "data" in result.stdout diff --git a/tests/utils/test_hash_key.py b/tests/utils/test_hash_key.py new file mode 100644 index 000000000..8512f5b1a --- /dev/null +++ b/tests/utils/test_hash_key.py @@ -0,0 +1,53 @@ +# 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 trpc_agent_sdk.utils._hash_key. + +Covers: +- user_key: basic generation, edge cases, special characters, uniqueness +""" + +from trpc_agent_sdk.utils import user_key + + +class TestUserKey: + """Test suite for user_key function.""" + + def test_basic(self): + assert user_key("app1", "user1") == "app1/user1" + + def test_different_apps(self): + r1 = user_key("app1", "user1") + r2 = user_key("app2", "user1") + assert r1 != r2 + assert r1 == "app1/user1" + assert r2 == "app2/user1" + + def test_different_users(self): + r1 = user_key("app1", "user1") + r2 = user_key("app1", "user2") + assert r1 != r2 + + def test_empty_strings(self): + assert user_key("", "") == "/" + + def test_special_chars(self): + assert user_key("app-name", "user_id-123") == "app-name/user_id-123" + + def test_unicode(self): + assert user_key("应用", "用户") == "应用/用户" + + def test_with_spaces(self): + assert user_key("my app", "my user") == "my app/my user" + + def test_return_type(self): + assert isinstance(user_key("a", "b"), str) + + def test_format_consistency(self): + result = user_key("x", "y") + parts = result.split("/") + assert len(parts) == 2 + assert parts[0] == "x" + assert parts[1] == "y" diff --git a/tests/utils/test_init.py b/tests/utils/test_init.py new file mode 100644 index 000000000..5ff77a51a --- /dev/null +++ b/tests/utils/test_init.py @@ -0,0 +1,53 @@ +# 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 trpc_agent_sdk.utils.__init__ re-exports. + +Covers: +- All public symbols are correctly re-exported via __all__ +- Each re-exported name resolves to the correct object +""" + +import trpc_agent_sdk.utils as utils_pkg +from trpc_agent_sdk.utils._context_utils import AsyncClosingContextManager as _ACM +from trpc_agent_sdk.utils._execute_cmd import CommandExecResult as _CER +from trpc_agent_sdk.utils._execute_cmd import async_execute_command as _aec +from trpc_agent_sdk.utils._hash_key import user_key as _uk +from trpc_agent_sdk.utils._json_repair import json_loads_repair as _jlr +from trpc_agent_sdk.utils._json_repair import json_repair_string as _jrs +from trpc_agent_sdk.utils._registry_factory import BaseRegistryFactory as _BRF +from trpc_agent_sdk.utils._singleton import SingletonBase as _SB +from trpc_agent_sdk.utils._singleton import SingletonMeta as _SM +from trpc_agent_sdk.utils._singleton import singleton as _sg + + +class TestAllExports: + + def test_all_contains_expected_names(self): + expected = { + "AsyncClosingContextManager", + "CommandExecResult", + "async_execute_command", + "user_key", + "json_loads_repair", + "json_repair_string", + "BaseRegistryFactory", + "SingletonBase", + "SingletonMeta", + "singleton", + } + assert set(utils_pkg.__all__) == expected + + def test_reexported_objects_match_originals(self): + assert utils_pkg.AsyncClosingContextManager is _ACM + assert utils_pkg.CommandExecResult is _CER + assert utils_pkg.async_execute_command is _aec + assert utils_pkg.user_key is _uk + assert utils_pkg.json_loads_repair is _jlr + assert utils_pkg.json_repair_string is _jrs + assert utils_pkg.BaseRegistryFactory is _BRF + assert utils_pkg.SingletonBase is _SB + assert utils_pkg.SingletonMeta is _SM + assert utils_pkg.singleton is _sg diff --git a/tests/utils/test_json_repair.py b/tests/utils/test_json_repair.py new file mode 100644 index 000000000..a8a0bd964 --- /dev/null +++ b/tests/utils/test_json_repair.py @@ -0,0 +1,176 @@ +# 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 trpc_agent_sdk.utils._json_repair. + +Covers: +- json_loads_repair: parses valid JSON, repairs malformed JSON, decodes bytes, + forwards kwargs, raises JSONDecodeError when unrecoverable. +- json_repair_string: returns a valid JSON string for valid/malformed inputs, + decodes bytes, forwards kwargs, raises JSONDecodeError when unrecoverable. +""" + +import json + +import pytest + +from trpc_agent_sdk.utils import json_loads_repair +from trpc_agent_sdk.utils import json_repair_string + + +class TestJsonLoadsRepair: + """Test suite for json_loads_repair.""" + + def test_valid_json_object(self): + assert json_loads_repair('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"} + + def test_valid_json_array(self): + assert json_loads_repair("[1, 2, 3]") == [1, 2, 3] + + def test_valid_json_scalar(self): + assert json_loads_repair("true") is True + assert json_loads_repair("null") is None + assert json_loads_repair("123") == 123 + + def test_missing_comma_repaired(self): + result = json_loads_repair('{"city": "Beijing" "unit": "celsius"}') + assert result == {"city": "Beijing", "unit": "celsius"} + + def test_trailing_comma_repaired(self): + result = json_loads_repair('{"a": 1, "b": 2,}') + assert result == {"a": 1, "b": 2} + + def test_single_quoted_keys_repaired(self): + result = json_loads_repair("{'a': 'b'}") + assert result == {"a": "b"} + + def test_unclosed_object_repaired(self): + result = json_loads_repair('{"a": 1, "b": 2') + assert result == {"a": 1, "b": 2} + + def test_code_fence_wrapped_json_repaired(self): + payload = '```json\n{"a": 1}\n```' + result = json_loads_repair(payload) + assert result == {"a": 1} + + def test_bytes_input_decoded_and_parsed(self): + result = json_loads_repair(b'{"a": 1}') + assert result == {"a": 1} + + def test_bytearray_input_decoded_and_repaired(self): + result = json_loads_repair(bytearray(b'{"a": 1 "b": 2}')) + assert result == {"a": 1, "b": 2} + + def test_non_utf8_bytes_replaced_not_raised(self): + # decode(errors="replace") should keep us off the exception path for + # invalid utf-8 bytes; the repaired result is still a usable JSON value. + result = json_loads_repair(b'\xff{"a": 1}') + assert isinstance(result, (dict, list, str, int, float, bool)) or result is None + + def test_unicode_preserved(self): + assert json_loads_repair('{"name": "北京"}') == {"name": "北京"} + + def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + def _boom(*_args, **_kwargs): + raise RuntimeError("unrecoverable") + + monkeypatch.setattr(module.json_repair, "loads", _boom) + + with pytest.raises(json.JSONDecodeError): + json_loads_repair('{"oops"') + + def test_kwargs_forwarded_to_json_repair(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + captured = {} + + def _fake_loads(value, **kwargs): + captured["value"] = value + captured["kwargs"] = kwargs + return {"ok": True} + + monkeypatch.setattr(module.json_repair, "loads", _fake_loads) + + result = json_loads_repair('{"x": 1}', skip_json_loads=True, logging=False) + + assert result == {"ok": True} + assert captured["value"] == '{"x": 1}' + assert captured["kwargs"] == {"skip_json_loads": True, "logging": False} + + +class TestJsonRepairString: + """Test suite for json_repair_string.""" + + def test_valid_json_returns_canonical_string(self): + repaired = json_repair_string('{"a": 1, "b": "x"}') + assert json.loads(repaired) == {"a": 1, "b": "x"} + + def test_missing_comma_repaired(self): + repaired = json_repair_string('{"city": "Beijing" "unit": "celsius"}') + assert json.loads(repaired) == {"city": "Beijing", "unit": "celsius"} + + def test_single_quoted_keys_repaired(self): + repaired = json_repair_string("{'a': 'b'}") + assert json.loads(repaired) == {"a": "b"} + + def test_unclosed_object_repaired(self): + repaired = json_repair_string('{"a": 1, "b": 2') + assert json.loads(repaired) == {"a": 1, "b": 2} + + def test_array_repaired(self): + repaired = json_repair_string("[1, 2 3,]") + assert json.loads(repaired) == [1, 2, 3] + + def test_bytes_input_decoded(self): + repaired = json_repair_string(b'{"a": 1}') + assert json.loads(repaired) == {"a": 1} + + def test_bytearray_input_decoded_and_repaired(self): + repaired = json_repair_string(bytearray(b'{"a": 1 "b": 2}')) + assert json.loads(repaired) == {"a": 1, "b": 2} + + def test_ensure_ascii_passthrough_default_preserves_unicode(self): + repaired = json_repair_string('{"name": "北京"}', ensure_ascii=False) + assert "北京" in repaired + assert json.loads(repaired) == {"name": "北京"} + + def test_ensure_ascii_true_escapes_unicode(self): + repaired = json_repair_string('{"name": "北京"}', ensure_ascii=True) + assert "北京" not in repaired + assert json.loads(repaired) == {"name": "北京"} + + def test_return_type_is_string(self): + assert isinstance(json_repair_string('{"a": 1}'), str) + + def test_unrecoverable_input_raises_json_decode_error(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + def _boom(*_args, **_kwargs): + raise RuntimeError("unrecoverable") + + monkeypatch.setattr(module.json_repair, "repair_json", _boom) + + with pytest.raises(json.JSONDecodeError): + json_repair_string('{"oops"') + + def test_kwargs_forwarded_to_json_repair(self, monkeypatch): + import trpc_agent_sdk.utils._json_repair as module + + captured = {} + + def _fake_repair_json(value, **kwargs): + captured["value"] = value + captured["kwargs"] = kwargs + return '{"ok": true}' + + monkeypatch.setattr(module.json_repair, "repair_json", _fake_repair_json) + + repaired = json_repair_string('{"x": 1}', ensure_ascii=False, logging=False) + + assert repaired == '{"ok": true}' + assert captured["value"] == '{"x": 1}' + assert captured["kwargs"] == {"ensure_ascii": False, "logging": False} diff --git a/tests/utils/test_registry_factory.py b/tests/utils/test_registry_factory.py new file mode 100644 index 000000000..73e532f86 --- /dev/null +++ b/tests/utils/test_registry_factory.py @@ -0,0 +1,181 @@ +# 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 trpc_agent_sdk.utils._registry_factory. + +Covers: +- BaseRegistryFactory: init, register, get_cls, get_instance, list_cls, + list_instance, create, create_and_save, error paths, copy semantics +""" + +import pytest + +from trpc_agent_sdk.utils import BaseRegistryFactory + + +class _SampleA: + def __init__(self, value=0): + self.value = value + + +class _SampleB: + pass + + +class TestBaseRegistryFactoryInit: + + def test_empty_maps(self): + factory = BaseRegistryFactory() + assert factory._cls_map == {} + assert factory._instance_map == {} + + +class TestBaseRegistryFactoryRegister: + + def test_register_returns_class(self): + factory = BaseRegistryFactory() + result = factory.register("a", _SampleA) + assert result is _SampleA + + def test_register_stores_in_cls_map(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + assert factory.get_cls("a") is _SampleA + + def test_register_multiple(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + factory.register("b", _SampleB) + assert len(factory.list_cls()) == 2 + + def test_register_duplicate_raises_type_error(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + with pytest.raises(TypeError, match="already registered"): + factory.register("a", _SampleB) + + +class TestBaseRegistryFactoryGetCls: + + def test_existing(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + assert factory.get_cls("a") is _SampleA + + def test_nonexistent_returns_none(self): + factory = BaseRegistryFactory() + assert factory.get_cls("missing") is None + + +class TestBaseRegistryFactoryGetInstance: + + def test_nonexistent_returns_none(self): + factory = BaseRegistryFactory() + assert factory.get_instance("missing") is None + + def test_after_create_and_save(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst = factory.create_and_save("a", "inst1", value=10) + assert factory.get_instance("inst1") is inst + + +class TestBaseRegistryFactoryListCls: + + def test_returns_copy(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + result = factory.list_cls() + assert result is not factory._cls_map + assert result == {"a": _SampleA} + + def test_mutating_copy_does_not_affect_original(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + result = factory.list_cls() + result["x"] = _SampleB + assert "x" not in factory._cls_map + + +class TestBaseRegistryFactoryListInstance: + + def test_returns_copy(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + factory.create_and_save("a", "inst1") + result = factory.list_instance() + assert result is not factory._instance_map + assert "inst1" in result + + def test_empty(self): + factory = BaseRegistryFactory() + assert factory.list_instance() == {} + + +class TestBaseRegistryFactoryCreate: + + def test_creates_instance(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst = factory.create("a", value=42) + assert isinstance(inst, _SampleA) + assert inst.value == 42 + + def test_creates_new_each_time(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst1 = factory.create("a") + inst2 = factory.create("a") + assert inst1 is not inst2 + + def test_nonexistent_raises_key_error(self): + factory = BaseRegistryFactory() + with pytest.raises(KeyError, match="No class registered"): + factory.create("missing") + + def test_with_positional_args(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst = factory.create("a", 99) + assert inst.value == 99 + + +class TestBaseRegistryFactoryCreateAndSave: + + def test_creates_and_stores(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst = factory.create_and_save("a", "inst1", value=42) + assert isinstance(inst, _SampleA) + assert inst.value == 42 + assert factory.get_instance("inst1") is inst + + def test_duplicate_instance_name_raises_key_error(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + factory.create_and_save("a", "inst1") + with pytest.raises(KeyError, match="Instance already exists"): + factory.create_and_save("a", "inst1") + + def test_nonexistent_cls_type_raises_key_error(self): + factory = BaseRegistryFactory() + with pytest.raises(KeyError, match="No class registered"): + factory.create_and_save("missing", "inst1") + + def test_multiple_instances_different_names(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + inst1 = factory.create_and_save("a", "inst1", value=1) + inst2 = factory.create_and_save("a", "inst2", value=2) + assert inst1 is not inst2 + assert factory.get_instance("inst1").value == 1 + assert factory.get_instance("inst2").value == 2 + + def test_different_cls_types_same_obj_name(self): + factory = BaseRegistryFactory() + factory.register("a", _SampleA) + factory.create_and_save("a", "shared_name") + with pytest.raises(KeyError, match="Instance already exists"): + factory.create_and_save("a", "shared_name") diff --git a/tests/utils/test_registry_factory_ext.py b/tests/utils/test_registry_factory_ext.py new file mode 100644 index 000000000..96a127e16 --- /dev/null +++ b/tests/utils/test_registry_factory_ext.py @@ -0,0 +1,86 @@ +# 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. +"""Extended tests for BaseRegistryFactory.""" + +import pytest + +from trpc_agent_sdk.utils._registry_factory import BaseRegistryFactory + + +class _DummyCls: + def __init__(self, val=0): + self.val = val + + +class TestBaseRegistryFactory: + """Test suite for BaseRegistryFactory.""" + + def test_register_and_get_cls(self): + """Test register and get_cls.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + assert reg.get_cls("dummy") is _DummyCls + + def test_register_duplicate_raises(self): + """Test duplicate register raises TypeError.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + with pytest.raises(TypeError, match="already registered"): + reg.register("dummy", _DummyCls) + + def test_get_cls_missing_returns_none(self): + """Test get_cls for missing returns None.""" + reg = BaseRegistryFactory() + assert reg.get_cls("nonexistent") is None + + def test_list_cls(self): + """Test list_cls returns all registered.""" + reg = BaseRegistryFactory() + reg.register("a", _DummyCls) + result = reg.list_cls() + assert "a" in result + + def test_create_instance(self): + """Test create returns new instance.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + instance = reg.create("dummy", val=42) + assert isinstance(instance, _DummyCls) + assert instance.val == 42 + + def test_create_missing_raises(self): + """Test create for missing raises KeyError.""" + reg = BaseRegistryFactory() + with pytest.raises(KeyError, match="No class registered"): + reg.create("nonexistent") + + def test_create_and_save(self): + """Test create_and_save creates and stores.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + instance = reg.create_and_save("dummy", "obj1", val=10) + assert reg.get_instance("obj1") is instance + + def test_create_and_save_duplicate_raises(self): + """Test create_and_save with existing name raises.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + reg.create_and_save("dummy", "obj1") + with pytest.raises(KeyError, match="already exists"): + reg.create_and_save("dummy", "obj1") + + def test_get_instance_missing(self): + """Test get_instance for missing returns None.""" + reg = BaseRegistryFactory() + assert reg.get_instance("nonexistent") is None + + def test_list_instance(self): + """Test list_instance returns all instances.""" + reg = BaseRegistryFactory() + reg.register("dummy", _DummyCls) + reg.create_and_save("dummy", "obj1") + result = reg.list_instance() + assert "obj1" in result diff --git a/tests/utils/test_singleton.py b/tests/utils/test_singleton.py new file mode 100644 index 000000000..ae867e292 --- /dev/null +++ b/tests/utils/test_singleton.py @@ -0,0 +1,232 @@ +# 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 trpc_agent_sdk.utils._singleton. + +Covers: +- singleton decorator: instance identity, args ignored after first call +- SingletonMeta metaclass: instance identity, _instances dict +- SingletonBase class: inheritance, __init__ called once +""" + +from trpc_agent_sdk.utils import SingletonBase, SingletonMeta, singleton + + +class TestSingletonDecorator: + + def test_same_instance(self): + @singleton + class A: + def __init__(self): + self.value = 42 + + assert A() is A() + assert A().value == 42 + + def test_different_classes(self): + @singleton + class X: + pass + + @singleton + class Y: + pass + + assert X() is X() + assert Y() is Y() + assert X() is not Y() + + def test_args_ignored_after_first_call(self): + @singleton + class C: + def __init__(self, v): + self.v = v + + first = C(10) + second = C(20) + assert first is second + assert second.v == 10 + + def test_kwargs_ignored_after_first_call(self): + @singleton + class D: + def __init__(self, name="default", age=0): + self.name = name + self.age = age + + first = D(name="Alice", age=25) + second = D(name="Bob", age=30) + assert first is second + assert first.name == "Alice" + + def test_many_instantiations(self): + @singleton + class E: + def __init__(self): + self.counter = 0 + self.counter += 1 + + instances = [E() for _ in range(10)] + for inst in instances: + assert inst is instances[0] + assert instances[0].counter == 1 + + def test_decorator_replaces_class(self): + @singleton + class F: + pass + + assert callable(F) + assert not isinstance(F, type) + + +class TestSingletonMeta: + + def test_same_instance(self): + class M1(metaclass=SingletonMeta): + def __init__(self): + self.value = 100 + + assert M1() is M1() + assert M1().value == 100 + + def test_different_classes(self): + class MA(metaclass=SingletonMeta): + pass + + class MB(metaclass=SingletonMeta): + pass + + assert MA() is MA() + assert MB() is MB() + assert MA() is not MB() + + def test_args_ignored_after_first(self): + class M2(metaclass=SingletonMeta): + def __init__(self, v): + self.v = v + + assert M2(50) is M2(60) + assert M2(50).v == 50 + + def test_kwargs_ignored_after_first(self): + class M3(metaclass=SingletonMeta): + def __init__(self, name="default"): + self.name = name + + assert M3(name="Test") is M3(name="Another") + assert M3().name == "Test" + + def test_instances_dict_populated(self): + class M4(metaclass=SingletonMeta): + pass + + M4() + assert M4 in SingletonMeta._instances + + +class TestSingletonBase: + + def test_same_instance(self): + class B1(SingletonBase): + def __init__(self): + super().__init__() + self.value = 200 + + assert B1() is B1() + assert B1().value == 200 + + def test_different_subclasses(self): + class BA(SingletonBase): + def __init__(self): + super().__init__() + self.name = "A" + + class BB(SingletonBase): + def __init__(self): + super().__init__() + self.name = "B" + + assert BA() is BA() + assert BB() is BB() + assert BA() is not BB() + + def test_init_called_once(self): + call_count = [] + + class B2(SingletonBase): + def __init__(self): + super().__init__() + call_count.append(1) + + for _ in range(5): + B2() + assert len(call_count) == 1 + + def test_inheritance_chain(self): + class Base(SingletonBase): + def __init__(self): + super().__init__() + self.base_val = "base" + + class Derived(Base): + def __init__(self): + super().__init__() + self.derived_val = "derived" + + d = Derived() + assert d is Derived() + assert d.base_val == "base" + assert d.derived_val == "derived" + + def test_with_args(self): + class B3(SingletonBase): + def __init__(self, v): + super().__init__() + self.v = v + + assert B3(300) is B3(400) + assert B3(300).v == 300 + + def test_is_instance_of_base(self): + class B4(SingletonBase): + pass + + assert isinstance(B4(), SingletonBase) + + +class TestSingletonComparison: + + def test_all_three_produce_singletons(self): + @singleton + class Dec: + pass + + class Meta(metaclass=SingletonMeta): + pass + + class Base(SingletonBase): + def __init__(self): + super().__init__() + + assert Dec() is Dec() + assert Meta() is Meta() + assert Base() is Base() + + def test_all_three_are_independent(self): + @singleton + class Dec2: + pass + + class Meta2(metaclass=SingletonMeta): + pass + + class Base2(SingletonBase): + def __init__(self): + super().__init__() + + assert Dec2() is not Meta2() + assert Dec2() is not Base2() + assert Meta2() is not Base2() diff --git a/trpc_agent_sdk/__init__.py b/trpc_agent_sdk/__init__.py new file mode 100644 index 000000000..a14fb3a05 --- /dev/null +++ b/trpc_agent_sdk/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""TRPC Agent SDK""" diff --git a/trpc_agent_sdk/_cli.py b/trpc_agent_sdk/_cli.py new file mode 100644 index 000000000..5c32371b4 --- /dev/null +++ b/trpc_agent_sdk/_cli.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +# 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. +"""Top-level CLI entrypoint for trpc_agent_sdk. + +This module provides: +1) A root Typer app (`trpc-agent` style command tree). +2) Explicit registration API via `register_cli`. +3) Auto-discovery for module CLIs named `*_cli.py`. + +To make a module discoverable automatically, define in `/_cli.py`: +- `app = typer.Typer(...)` +- optional `CLI_COMMAND_PATH = ("group", "name")` +- optional `CLI_COMMAND_HELP = "help text"` + +If `CLI_COMMAND_PATH` is omitted, command path is derived from the module path. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from dataclasses import dataclass +from typing import Iterable +from typing import Optional + +import typer + +_PACKAGE_PREFIX = "trpc_agent_sdk." +_ROOT_CLI_MODULE = "trpc_agent_sdk._cli" + + +@dataclass(frozen=True) +class _CliRegistration: + """Metadata for a sub CLI module.""" + + module_path: str + command_path: tuple[str, ...] | None = None + app_attr: str = "app" + help_text: str | None = None + + +_REGISTRATIONS: list[_CliRegistration] = [] +_REGISTERED_MODULES: set[str] = set() +_LOAD_ERRORS: list[str] = [] + + +def register_cli( + module_path: str, + *, + command_path: Iterable[str] | None = None, + app_attr: str = "app", + help_text: str | None = None, +) -> None: + """Register a module that exposes a Typer app. + + Args: + module_path: Python module path,. + command_path: Optional command hierarchy. + If omitted, module-level `CLI_COMMAND_PATH` or derived path is used. + app_attr: Attribute name that points to a `typer.Typer` app. + help_text: Optional help text shown for the subcommand. + """ + if module_path in _REGISTERED_MODULES: + return + + normalized_command_path = tuple(command_path) if command_path is not None else None + _REGISTRATIONS.append( + _CliRegistration( + module_path=module_path, + command_path=normalized_command_path, + app_attr=app_attr, + help_text=help_text, + )) + _REGISTERED_MODULES.add(module_path) + + +def _derive_command_path_from_module(module_path: str) -> tuple[str, ...]: + name = module_path + if name.startswith(_PACKAGE_PREFIX): + name = name[len(_PACKAGE_PREFIX):] + parts = [part for part in name.split(".") if part] + if parts and parts[-1] == "_cli": + parts = parts[:-1] + command_parts = [part.lstrip("_").replace("_", "-") for part in parts if not part.startswith("__")] + if not command_parts: + raise ValueError(f"Cannot derive command path from module: {module_path}") + return tuple(command_parts) + + +def _normalize_command_path(path: Iterable[str]) -> tuple[str, ...]: + normalized = tuple(segment.strip() for segment in path if segment and segment.strip()) + if not normalized: + raise ValueError("Command path must contain at least one non-empty segment.") + return normalized + + +def _auto_discover_cli_modules() -> None: + """Discover `*_cli.py` modules under trpc_agent_sdk and register them.""" + package = importlib.import_module("trpc_agent_sdk") + for module_info in pkgutil.walk_packages(package.__path__, prefix=f"{package.__name__}."): + module_name = module_info.name + if module_name in {__name__, _ROOT_CLI_MODULE}: + continue + if not module_name.endswith("._cli"): + continue + register_cli(module_name) + + +def _add_typer_at_path( + root_app: typer.Typer, + group_apps: dict[tuple[str, ...], typer.Typer], + command_path: tuple[str, ...], + sub_app: typer.Typer, + help_text: str | None = None, +) -> None: + parent = root_app + parent_key: tuple[str, ...] = () + for segment in command_path[:-1]: + group_key = parent_key + (segment, ) + group = group_apps.get(group_key) + if group is None: + group = typer.Typer(no_args_is_help=True, add_completion=False) + parent.add_typer(group, name=segment) + group_apps[group_key] = group + parent = group + parent_key = group_key + parent.add_typer(sub_app, name=command_path[-1], help=help_text) + + +def _make_missing_module_callback(module_path: str, err_text: str): + + def _callback() -> None: + typer.echo( + f"[ERROR] Failed to load sub command module '{module_path}': {err_text}", + err=True, + ) + raise typer.Exit(code=1) + + return _callback + + +def _build_app() -> typer.Typer: + app = typer.Typer( + help="tRPC Agent SDK command line tools.", + no_args_is_help=True, + add_completion=False, + ) + register_cli( + "trpc_agent_sdk.server.openclaw._cli", + command_path=("openclaw", ), + help_text="OpenClaw gateway, chat, ui and deps tools.", + ) + _auto_discover_cli_modules() + + group_apps: dict[tuple[str, ...], typer.Typer] = {} + + for registration in _REGISTRATIONS: + raw_path = registration.command_path + if raw_path is None: + command_path = _derive_command_path_from_module(registration.module_path) + else: + command_path = _normalize_command_path(raw_path) + help_text = registration.help_text + + try: + module = importlib.import_module(registration.module_path) + sub_app = getattr(module, registration.app_attr) + except Exception as exc: # pylint: disable=broad-except + err_text = str(exc) + _LOAD_ERRORS.append(f"{registration.module_path}: {err_text}") + failed_app = typer.Typer( + no_args_is_help=True, + add_completion=False, + help=f"Failed to load module: {registration.module_path}", + ) + failed_app.callback()(_make_missing_module_callback(registration.module_path, err_text)) + _add_typer_at_path( + app, + group_apps, + command_path, + failed_app, + help_text=help_text, + ) + continue + + if not isinstance(sub_app, typer.Typer): + _LOAD_ERRORS.append( + f"{registration.module_path}: attribute '{registration.app_attr}' is not a typer.Typer instance") + continue + + raw_path = registration.command_path or getattr(module, "CLI_COMMAND_PATH", None) + if raw_path is None: + command_path = _derive_command_path_from_module(registration.module_path) + else: + command_path = _normalize_command_path(raw_path) + + help_text = registration.help_text or getattr(module, "CLI_COMMAND_HELP", None) + _add_typer_at_path(app, group_apps, command_path, sub_app, help_text=help_text) + + @app.callback(invoke_without_command=True) + def _root_callback(ctx: typer.Context) -> None: + if not _LOAD_ERRORS or ctx.invoked_subcommand is not None: + return + for err in _LOAD_ERRORS: + typer.echo(f"[WARN] Failed to load sub command: {err}", err=True) + + return app + + +app = _build_app() + + +def main(argv: Optional[list[str]] = None) -> int: + """CLI entrypoint.""" + try: + app(args=argv, prog_name="trpc_agent_cmd") + except SystemExit as exc: + if isinstance(exc.code, int): + return exc.code + return 0 if exc.code is None else 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/trpc_agent_sdk/abc/__init__.py b/trpc_agent_sdk/abc/__init__.py new file mode 100644 index 000000000..5a3f75125 --- /dev/null +++ b/trpc_agent_sdk/abc/__init__.py @@ -0,0 +1,64 @@ +# 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. +"""TRPC Agent Base Class Module. + +This module defines the AgentABC class which serves as the foundation for all +agent implementations in the TRPC Agent Development Kit. +""" + +from ..types import MemoryEntry +from ..types import SearchMemoryResponse +from ._agent import AgentABC +from ._artifact_service import ArtifactEntry +from ._artifact_service import ArtifactId +from ._artifact_service import ArtifactServiceABC +from ._artifact_service import ArtifactVersion +from ._filter import FilterABC +from ._filter import FilterAsyncGenHandleType +from ._filter import FilterAsyncGenReturnType +from ._filter import FilterHandleType +from ._filter import FilterResult +from ._filter import FilterReturnType +from ._filter import FilterType +from ._memory_service import MemoryServiceABC +from ._memory_service import MemoryServiceConfig +from ._planner import PlannerABC +from ._request import RequestABC +from ._response import ResponseABC +from ._session import SessionABC +from ._session_service import ListSessionsResponse +from ._session_service import SessionServiceABC +from ._tool import ToolABC +from ._toolset import ToolPredicate +from ._toolset import ToolSetABC + +__all__ = [ + "MemoryEntry", + "SearchMemoryResponse", + "AgentABC", + "ArtifactEntry", + "ArtifactId", + "ArtifactServiceABC", + "ArtifactVersion", + "FilterABC", + "FilterAsyncGenHandleType", + "FilterAsyncGenReturnType", + "FilterHandleType", + "FilterResult", + "FilterReturnType", + "FilterType", + "MemoryServiceABC", + "MemoryServiceConfig", + "PlannerABC", + "RequestABC", + "ResponseABC", + "SessionABC", + "ListSessionsResponse", + "SessionServiceABC", + "ToolABC", + "ToolPredicate", + "ToolSetABC", +] diff --git a/trpc_agent_sdk/abc/_agent.py b/trpc_agent_sdk/abc/_agent.py new file mode 100644 index 000000000..fe953ded5 --- /dev/null +++ b/trpc_agent_sdk/abc/_agent.py @@ -0,0 +1,187 @@ +# 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. +"""TRPC Agent Base Class Module. + +This module defines the AgentABC class which serves as the foundation for all +agent implementations in the TRPC Agent Development Kit. + +Key Features: + - Core agent lifecycle management + - Filter pipeline execution + - Context propagation + - Sub-agent hierarchy management + - Callback handling (before/after execution) +""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from typing import AsyncGenerator +from typing import Optional +from typing import TYPE_CHECKING +from typing_extensions import override + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + +from ._filter import FilterABC + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + + +class AgentABC(BaseModel): + """Base class for all agents in Agent Development Kit. + + Provides core functionality for agent execution including: + - Filter management and execution + - Asynchronous operation handling + - Context management + - Agent hierarchy management + + Attributes: + name: The agent's name, must be a Python identifier and unique within the agent tree + description: Description about the agent's capability + parent_agent: The parent agent of this agent + sub_agents: The sub-agents of this agent + filters_name: List of filter names that will be applied during agent execution + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + ) + """The pydantic model config.""" + + name: str + """The agent's name. + + Agent name must be a Python identifier and unique within the agent tree. + Agent name cannot be "user", since it's reserved for end-user's input. + """ + + description: str = "" + """Description about the agent's capability. + + The model uses this to determine whether to delegate control to the agent. + One-line description is enough and preferred. + """ + + parent_agent: Optional[AgentABC] = Field(default=None, init=False) + """The parent agent of this agent. + + Note that an agent can ONLY be added as sub-agent once. + + If you want to add one agent twice as sub-agent, consider to create two agent + instances with identical config, but with different name and add them to the + agent tree. + """ + + sub_agents: list[AgentABC] = Field(default_factory=list) + """The sub-agents of this agent.""" + + filters_name: list[str] = Field(default_factory=list) + """List of filter names that will be applied during agent execution.""" + + filters: list[FilterABC] = Field(default_factory=list) + """List of filter instances that will be applied during agent execution.""" + + disallow_transfer_to_parent: bool = False + """Disallow transferring control to parent agent.""" + + disallow_transfer_to_peers: bool = False + """Disallow transferring control to peer agents.""" + + @property + def root_agent(self) -> AgentABC: + """Gets the root agent of this agent.""" + root_agent = self + while root_agent.parent_agent is not None: + root_agent = root_agent.parent_agent + return root_agent + + def get_agent_class(self) -> type[AgentABC]: + """Return the runtime class of this agent instance. + + Examples: + - AgentABC subclass instance -> that concrete subclass type + - LlmAgent instance -> LlmAgent + """ + return self.__class__ + + def get_agent_type_name(self) -> str: + """Return the runtime class name of this agent instance.""" + return self.get_agent_class().__name__ + + @abstractmethod + def get_subagents(self) -> list[AgentABC]: + """Return the list of agents used as direct children for lookup. + + Subclasses must implement. Used by find_sub_agent (or overridden logic) + when resolving agent by name. + + Returns: + List of agents considered as direct children for lookup. + """ + ... + + def find_agent(self, name: str) -> Optional[AgentABC]: + """Finds the agent with the given name in this agent and its descendants. + + Args: + name: The name of the agent to find. + + Returns: + The agent with the matching name, or None if no such agent is found. + """ + if self.name == name: + return self + return self.find_sub_agent(name) + + def find_sub_agent(self, name: str) -> Optional[AgentABC]: + """Finds the agent with the given name in this agent's descendants. + + Args: + name: The name of the agent to find. + + Returns: + The agent with the matching name, or None if no such agent is found. + """ + for sub_agent in self.sub_agents: + if result := sub_agent.find_agent(name): + return result + return None + + @override + def model_post_init(self, __context: Any) -> None: + """Sets the parent agent for all sub-agents.""" + self.__set_parent_agent_for_sub_agents() + + def __set_parent_agent_for_sub_agents(self) -> AgentABC: + """Sets the parent agent for all sub-agents.""" + for sub_agent in self.sub_agents: + if sub_agent.parent_agent is not None: + raise ValueError(f"Agent `{sub_agent.name}` already has a parent agent, current" + f" parent: `{sub_agent.parent_agent.name}`, trying to add:" + f" `{self.name}`") + sub_agent.parent_agent = self + return self + + @abstractmethod + async def run_async( + self, + parent_context: InvocationContext, + ) -> AsyncGenerator[Any, None]: + """Entry point for agent execution. + + Args: + parent_context: The parent context of the agent. + + Returns: + An async generator of events. + """ diff --git a/trpc_agent_sdk/abc/_artifact_service.py b/trpc_agent_sdk/abc/_artifact_service.py new file mode 100644 index 000000000..84ddef0bb --- /dev/null +++ b/trpc_agent_sdk/abc/_artifact_service.py @@ -0,0 +1,202 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Base artifact service interface and implementations.""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from typing import Dict +from typing import Optional + +from google.genai.types import Part +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import alias_generators + + +class ArtifactVersion(BaseModel): + """Metadata describing a specific version of an artifact.""" + + model_config = ConfigDict( + alias_generator=alias_generators.to_camel, + populate_by_name=True, + ) + + version: int = Field(description=("Monotonically increasing identifier for the artifact version.")) + canonical_uri: str = Field(description="Canonical URI referencing the persisted artifact payload.") + custom_metadata: dict[str, Any] = Field( + default_factory=dict, + description="Optional user-supplied metadata stored with the artifact.", + ) + create_time: float = Field( + default_factory=lambda: datetime.now().timestamp(), + description=("Unix timestamp (seconds) when the version record was created."), + ) + mime_type: Optional[str] = Field( + default=None, + description=("MIME type when the artifact payload is stored as binary data."), + ) + + +class ArtifactId(BaseModel): + """Identifier for an artifact.""" + + model_config = ConfigDict( + alias_generator=alias_generators.to_camel, + populate_by_name=True, + ) + app_name: str = Field(description="The name of the application.") + user_id: str = Field(description="The ID of the user.") + session_id: Optional[str] = Field(default=None, + description="The ID of the session. If None, the artifact is user-scoped.") + filename: str = Field(default="", description="The filename of the artifact.") + + +@dataclass +class ArtifactEntry: + """Represents a single version of an artifact stored in memory. + + Attributes: + data: The actual data of the artifact. + artifact_version: Metadata about this specific version of the artifact. + """ + + data: Part + version: ArtifactVersion + + +class ArtifactServiceABC(ABC): + """Abstract base class for artifact management services.""" + + @abstractmethod + async def save_artifact(self, artifact_id: ArtifactId, artifact: Part, metadata: Dict[str, Any]) -> int: + """Save an artifact and return its version. + + Args: + artifact_id: The identifier for the artifact. + artifact: The artifact to save. + metadata: The metadata for the artifact. + + Returns: + The version of the artifact. + """ + pass + + @abstractmethod + async def load_artifact( + self, + *, + artifact_id: ArtifactId, + version: Optional[int] = None, + ) -> Optional[ArtifactEntry]: + """Gets an artifact from the artifact service storage. + + The artifact is a file identified by the app name, user ID, session ID, and + filename. + + Args: + artifact_id: The identifier for the artifact. + version: The version of the artifact. If None, the latest version will be + returned. + + Returns: + The artifact or None if not found. + """ + + @abstractmethod + async def list_artifact_keys(self, *, artifact_id: ArtifactId) -> list[str]: + """Lists all the versions of an artifact. + + Args: + artifact_id: The identifier for the artifact. + + Returns: + A list of all versions of the artifact. + """ + + @abstractmethod + async def delete_artifact( + self, + *, + artifact_id: ArtifactId, + ) -> None: + """Deletes an artifact. + + Args: + artifact_id: The identifier for the artifact. + """ + + @abstractmethod + async def list_versions( + self, + *, + artifact_id: ArtifactId, + ) -> list[int]: + """Lists all versions of an artifact. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + filename: The name of the artifact file. + session_id: The ID of the session. If `None`, only list the user-scoped + artifacts versions. + + Returns: + A list of all available versions of the artifact. + """ + + @abstractmethod + async def list_artifact_versions( + self, + *, + artifact_id: ArtifactId, + ) -> list[ArtifactVersion]: + """Lists all versions and their metadata for a specific artifact. + + Args: + artifact_id: The identifier for the artifact. + + Returns: + A list of ArtifactVersion objects, each representing a version of the + artifact and its associated metadata. + """ + + @abstractmethod + async def get_artifact_version(self, + artifact_id: ArtifactId, + version: Optional[int] = None) -> Optional[ArtifactVersion]: + """Retrieve a specific version of an artifact. + + Args: + artifact_id: The identifier for the artifact. + version: The version of the artifact. If None, the latest version will be + returned. + + Returns: + The artifact version or None if not found. + """ diff --git a/trpc_agent_sdk/abc/_filter.py b/trpc_agent_sdk/abc/_filter.py new file mode 100644 index 000000000..b73023e3a --- /dev/null +++ b/trpc_agent_sdk/abc/_filter.py @@ -0,0 +1,232 @@ +# 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. +""" +TRPC Agent Filter System Core Abstractions. + +This module defines the fundamental building blocks for the TRPC Agent filter system, +providing: + +1. Base Classes: + - FilterABC: Abstract base class defining the filter interface + - FilterResult: Standardized container for filter outputs + +2. Type System: + - FilterType: Enumeration of filter categories + - Generic type variables for context and request/response types + - Type aliases for filter handlers and results + +3. Core Features: + - Async-first design with full async generator support + - Type-safe filter execution pipeline + - Comprehensive error handling + - Extensible filter categorization + +Example Usage: + class MyFilter(FilterABC): + async def _before(self, ctx, req): + # Pre-processing logic + yield FilterResult(...) + + async def _after(self, ctx, req): + # Post-processing logic + yield FilterResult(...) +""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from enum import IntEnum +from enum import unique +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import Generic +from typing import Optional +from typing import TYPE_CHECKING +from typing import Tuple +from typing import TypeVar + +if TYPE_CHECKING: + from trpc_agent_sdk.context import AgentContext + +FilterRspType = TypeVar('FilterRspType') + + +@dataclass +class FilterResult(Generic[FilterRspType]): + """Filter result.""" + """Standardized result container for filter operations. + + Attributes: + rsp: Response data produced by the filter (optional) + error: Exception encountered during execution (optional) + """ + rsp: Optional[FilterRspType] = None + """The response data produced by filter execution.""" + + error: Optional[Exception] = None + """The exception encountered during filter execution.""" + + is_continue: bool = True + """Whether to continue processing.""" + + def __iter__(self): + return iter([self.rsp, self.error]) + + +# Type aliases +FilterReturnType = Tuple[Any, Optional[Exception]] +"""Type alias for filter return value (result, error) tuple.""" + +FilterHandleType = Callable[[], Awaitable[FilterReturnType]] +"""Type alias for async filter handler function.""" + +FilterAsyncGenReturnType = AsyncGenerator[FilterResult, None] +"""Type alias for async generator filter return type.""" + +FilterAsyncGenHandleType = Callable[[], AsyncGenerator[FilterResult, None]] +"""Type alias for async generator filter handler function.""" + + +@unique +class FilterType(IntEnum): + """Enumeration of filter types used in the filtering system. + + Each type represents a distinct category of filters with specific purposes. + The @unique decorator ensures all values are distinct. + """ + + # Filter categories + UNSUPPORTED = -1 + """Unsupported filters.""" + + TOOL = 0 + """Filters for tool processing and manipulation.""" + + MODEL = 1 + """Filters for model processing and manipulation.""" + + AGENT = 2 + """Filters for agent processing and manipulation.""" + + +class FilterABC(ABC): + """Abstract base class defining the filter interface. + + All concrete filters must implement these methods to be compatible + with the filter management system. + """ + + def __init__(self) -> None: + super().__init__() + self._type = FilterType.UNSUPPORTED + self._name = "" + + @property + def full_name(self): + """Get the full name of the filter.""" + return f"{self._type.name}_{self._name}" + + @property + def type(self) -> FilterType: + """Get the filter type.""" + return self._type + + @type.setter + def type(self, value: FilterType): + """Set the filter type.""" + self._type = value + + @property + def name(self) -> str: + """Get the filter name.""" + return self._name + + @name.setter + def name(self, value: str): + """Set the filter name.""" + self._name = value + + @abstractmethod + async def _before(self, ctx: "AgentContext", req: Any, rsp: FilterResult): + """Execute before. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + + Returns: + None + """ + return None + + @abstractmethod + async def _after(self, ctx: "AgentContext", req: Any, rsp: FilterResult): + """Execute after. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + Returns: + None + """ + return None + + @abstractmethod + async def _after_every_stream(self, ctx: "AgentContext", req: Any, rsp: FilterResult) -> None: + """Execute after every stream. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + Returns: + None + """ + return None + + def _create_err_str(self, msg: str) -> str: + """Create an error string with filter information. + + Args: + msg: Error message + + Returns: + str: Formatted error string + """ + return f"filter type: '{self._type.name}', name: '{self._name}': ({msg})" + + @abstractmethod + async def run_stream(self, ctx: "AgentContext", req: Any, + handle: FilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + """Execute the full filter lifecycle (before -> handle -> after). + + Args: + ctx: AgentContext + req: Request data + handle: Next handler in the chain + + Returns: + FilterResult: Combined result of all operations + """ + + @abstractmethod + async def run(self, ctx: "AgentContext", req: Any, handle: FilterHandleType) -> FilterResult: + """Execute the full filter lifecycle (before -> handle -> after). + + Args: + ctx: AgentContext + req: Request data + handle: Next handler in the chain + + Returns: + FilterResult: Combined result of all operations + """ diff --git a/trpc_agent_sdk/abc/_memory_service.py b/trpc_agent_sdk/abc/_memory_service.py new file mode 100644 index 000000000..0b5148962 --- /dev/null +++ b/trpc_agent_sdk/abc/_memory_service.py @@ -0,0 +1,90 @@ +# 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. +"""Base memory service interface and implementations.""" + +from __future__ import annotations + +import time +from abc import ABC +from abc import abstractmethod +from typing import Optional +from typing import TYPE_CHECKING + +from pydantic import BaseModel +from pydantic import Field + +if TYPE_CHECKING: + from trpc_agent_sdk.context import AgentContext + +from ._session import SessionABC +from ..types import Ttl +from ..types import SearchMemoryResponse +from ..types import DEFAULT_TTL_SECONDS +from ..types import DEFAULT_CLEANUP_INTERVAL_SECONDS + + +class MemoryServiceConfig(BaseModel): + """Memory service configuration.""" + enabled: bool = Field(default=False, description="Whether the memory service is enabled") + """Whether the memory service is enabled. If False, the memory service will not store any data.""" + ttl: Ttl = Field(default_factory=Ttl) + """TTL configuration for the memory service.""" + + @staticmethod + def create_ttl_config(enable: bool = True, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + cleanup_interval_seconds: float = DEFAULT_CLEANUP_INTERVAL_SECONDS) -> Ttl: + """Initialize from TTL configuration.""" + return Ttl(enable=enable, + ttl_seconds=ttl_seconds, + cleanup_interval_seconds=cleanup_interval_seconds, + update_time=time.time()) + + def clean_ttl_config(self) -> None: + """Clean up the TTL configuration.""" + self.ttl.clean_ttl_config() + + +class MemoryServiceABC(ABC): + """Abstract base class for memory/RAG services.""" + + def __init__(self, memory_service_config: Optional[MemoryServiceConfig] = None, enabled: bool = False) -> None: + if memory_service_config is None: + memory_service_config = MemoryServiceConfig(enabled=enabled) + memory_service_config.clean_ttl_config() + self._memory_service_config = memory_service_config + + @property + def enabled(self) -> bool: + return self._memory_service_config.enabled + + @abstractmethod + async def store_session(self, session: SessionABC, agent_context: Optional["AgentContext"] = None) -> None: + """Store content in memory for future retrieval. + + Args: + session: The session to store in memory. + agent_context: The agent context for user interaction control. + """ + + @abstractmethod + async def search_memory(self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional["AgentContext"] = None) -> SearchMemoryResponse: + """Search memory for relevant content. + + Args: + key: The session id to search memory for. + query: The query to search memory for. + limit: The maximum number of results to return. + agent_context: The agent context for user interaction control. + """ + + @abstractmethod + async def close(self) -> None: + """Close the memory service.""" diff --git a/trpc_agent_sdk/abc/_planner.py b/trpc_agent_sdk/abc/_planner.py new file mode 100644 index 000000000..3aafce6cb --- /dev/null +++ b/trpc_agent_sdk/abc/_planner.py @@ -0,0 +1,91 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Base Planner module for TRPC Agent framework. + +This module provides the abstract base class for all planners in the system. +Planners guide agent thinking and action execution through structured approaches. +""" + +from __future__ import annotations + +import abc +from abc import ABC +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from google.genai.types import Part + +from ._request import RequestABC + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + + +class PlannerABC(ABC): + """Abstract base class for all planners. + + The planner allows the agent to generate plans for queries to guide its + action execution. Planners provide two main capabilities: + 1. Adding planning instructions to LLM requests + 2. Processing LLM responses to filter and organize content + """ + + @abc.abstractmethod + def build_planning_instruction( + self, + context: InvocationContext, + llm_request: RequestABC, + ) -> Optional[str]: + """Builds the system instruction to be appended to the LLM request for planning. + + Args: + context: The invocation context containing session and agent info + llm_request: The LLM request being built (readonly for planning) + + Returns: + The planning system instruction, or None if no instruction is needed + """ + pass + + @abc.abstractmethod + def process_planning_response( + self, + context: InvocationContext, + response_parts: List[Part], + is_partial: bool = False, + ) -> Optional[List[Part]]: + """Processes the LLM response for planning. + + This method can filter, reorganize, or modify the response parts to + separate planning/reasoning content from final user-facing content. + + Args: + context: The invocation context for state access + response_parts: The LLM response parts (readonly) + is_partial: Whether this is a partial response (streaming) + + Returns: + The processed response parts, or None if no processing is needed + """ diff --git a/trpc_agent_sdk/abc/_request.py b/trpc_agent_sdk/abc/_request.py new file mode 100644 index 000000000..ca3e1221d --- /dev/null +++ b/trpc_agent_sdk/abc/_request.py @@ -0,0 +1,93 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Base request class for TRPC Agent framework.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from typing import Optional + +from google.genai.types import Content +from google.genai.types import GenerateContentConfig +from google.genai.types import LiveConnectConfig +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + + +class RequestABC(BaseModel): + """LLM request class that allows passing in tools, output schema and system + + instructions to the model. + + Attributes: + model: The model name. + contents: The contents to send to the model. + config: Additional config for the generate content request. + tools_dict: The tools dictionary. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + """The pydantic model config.""" + + model: Optional[str] = None + """The model name.""" + + contents: list[Content] = Field(default_factory=list) + """The contents to send to the model.""" + + config: Optional[GenerateContentConfig] = None + """Additional config for the generate content request.""" + + live_connect_config: LiveConnectConfig = LiveConnectConfig() + """Additional config for the generate content request. + + tools in generate_content_config should not be set. + """ + tools_dict: dict[str, Any] = Field(default_factory=dict, exclude=True) + """The tools dictionary.""" + + @abstractmethod + def append_instructions(self, instructions: list[str]) -> None: + """Appends instructions to the system instruction. + + Args: + instructions: The instructions to append. + """ + + @abstractmethod + def append_tools(self, tools: list[Any]) -> None: + """Appends tools to the request. + + Args: + tools: The tools to append. + """ + + @abstractmethod + def set_output_schema(self, base_model: type[BaseModel]) -> None: + """Sets the output schema for the request. + + Args: + base_model: The pydantic base model to set the output schema to. + """ diff --git a/trpc_agent_sdk/abc/_response.py b/trpc_agent_sdk/abc/_response.py new file mode 100644 index 000000000..b6fedf271 --- /dev/null +++ b/trpc_agent_sdk/abc/_response.py @@ -0,0 +1,123 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Base response class for TRPC Agent framework.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from typing import Optional + +from google.genai.types import Content +from google.genai.types import GenerateContentResponse +from google.genai.types import GroundingMetadata + +from trpc_agent_sdk.types import GenerateContentResponseUsageMetadata +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import alias_generators + + +class ResponseABC(BaseModel): + """LLM response class that provides the first candidate response from the + + model if available. Otherwise, returns error code and message. + + Attributes: + content: The content of the response. + grounding_metadata: The grounding metadata of the response. + partial: Indicates whether the text content is part of a unfinished text + stream. Only used for streaming mode and when the content is plain text. + turn_complete: Indicates whether the response from the model is complete. + Only used for streaming mode. + error_code: Error code if the response is an error. Code varies by model. + error_message: Error message if the response is an error. + interrupted: Flag indicating that LLM was interrupted when generating the + content. Usually it's due to user interruption during a bidi streaming. + custom_metadata: The custom metadata of the LlmResponse. + """ + + model_config = ConfigDict( + extra="forbid", + alias_generator=alias_generators.to_camel, + populate_by_name=True, + ) + """The pydantic model config.""" + + content: Optional[Content] = None + """The content of the response.""" + + grounding_metadata: Optional[GroundingMetadata] = None + """The grounding metadata of the response.""" + + partial: Optional[bool] = None + """Indicates whether the text content is part of a unfinished text stream. + + Only used for streaming mode and when the content is plain text. + """ + + turn_complete: Optional[bool] = None + """Indicates whether the response from the model is complete. + + Only used for streaming mode. + """ + + error_code: Optional[str] = None + """Error code if the response is an error. Code varies by model.""" + + error_message: Optional[str] = None + """Error message if the response is an error.""" + + interrupted: Optional[bool] = None + """Flag indicating that LLM was interrupted when generating the content. + Usually it's due to user interruption during a bidi streaming. + """ + + custom_metadata: Optional[dict[str, Any]] = None + """The custom metadata of the LlmResponse. + + An optional key-value pair to label an LlmResponse. + + NOTE: the entire dict must be JSON serializable. + """ + + usage_metadata: Optional[GenerateContentResponseUsageMetadata] = None + """The usage metadata of the LlmResponse""" + + response_id: Optional[str] = None + """The response ID from the model API.""" + + @abstractmethod + def create( + self, + generate_content_response: GenerateContentResponse, + ) -> ResponseABC: + """Creates an LlmResponse from a GenerateContentResponse. + + Args: + generate_content_response: The GenerateContentResponse to create the + LlmResponse from. + + Returns: + The LlmResponse. + """ diff --git a/trpc_agent_sdk/abc/_session.py b/trpc_agent_sdk/abc/_session.py new file mode 100644 index 000000000..97d1e59a7 --- /dev/null +++ b/trpc_agent_sdk/abc/_session.py @@ -0,0 +1,63 @@ +# 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 data structure.""" + +from __future__ import annotations + +import time +from typing import Any +from typing import Dict + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import alias_generators + + +class SessionABC(BaseModel): + """Represents a series of interactions between a user and agents. + + This class manages the state and events of a conversation session, + providing methods to add events, update state, and track session metadata. + + Attributes: + id: The unique identifier of the session. + app_name: The name of the application. + user_id: The id of the user. + state: The state of the session as a dictionary. + events: The events of the session, e.g. user input, model response, + function call/response, etc. + last_update_time: The last update time as a float timestamp. + """ + + model_config = ConfigDict( + extra='forbid', + arbitrary_types_allowed=True, + alias_generator=alias_generators.to_camel, + populate_by_name=True, + ) + """The pydantic model config.""" + + id: str = Field(..., description="The unique identifier of the session") + """The unique identifier of the session.""" + + app_name: str = Field(..., description="The name of the application") + """The name of the application.""" + + user_id: str = Field(..., description="The id of the user") + """The id of the user.""" + + state: Dict[str, Any] = Field(default_factory=dict, description="The state of the session") + """The state of the session.""" + + last_update_time: float = Field(default_factory=time.time, description="The last update time as a float timestamp") + """The last update time as a float timestamp.""" + + save_key: str = Field(..., description="The key to save the session") + """The key to save the session.""" + + conversation_count: int = Field(default=0, description="The count of the conversation") + """The count of the conversation.""" diff --git a/trpc_agent_sdk/abc/_session_service.py b/trpc_agent_sdk/abc/_session_service.py new file mode 100644 index 000000000..15107b2ee --- /dev/null +++ b/trpc_agent_sdk/abc/_session_service.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 Apache-2.0. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Base session service interface.""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Optional +from typing import TYPE_CHECKING + +from pydantic import BaseModel +from pydantic import Field + +from ._response import ResponseABC +from ._session import SessionABC + +if TYPE_CHECKING: + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.context import InvocationContext + + +class ListSessionsResponse(BaseModel): + """The response of listing sessions. + + The events and states are not set within each Session object. + """ + + sessions: list[SessionABC] = Field(default_factory=list) + + +class SessionServiceABC(ABC): + """Abstract base class for session management services. + + The service provides a set of methods for managing sessions and events. + """ + + @abstractmethod + async def create_session( + self, + *, + app_name: str, + user_id: str, + state: Optional[dict[str, Any]] = None, + session_id: Optional[str] = None, + agent_context: Optional[AgentContext] = None, + ) -> SessionABC: + """Creates a new session. + + Args: + app_name: the name of the app. + user_id: the id of the user. + state: the initial state of the session. + session_id: the client-provided id of the session. If not provided, a + generated ID will be used. + + Returns: + session: The newly created session instance. + """ + + @abstractmethod + async def get_session( + self, + *, + app_name: str, + user_id: str, + session_id: str, + agent_context: Optional[AgentContext] = None, + ) -> Optional[SessionABC]: + """Gets a session.""" + + @abstractmethod + async def list_sessions(self, *, app_name: str, user_id: str) -> ListSessionsResponse: + """Lists all the sessions.""" + + @abstractmethod + async def delete_session(self, *, app_name: str, user_id: str, session_id: str) -> None: + """Deletes a session.""" + + @abstractmethod + async def append_event(self, session: SessionABC, event: ResponseABC) -> ResponseABC: + """Appends an event to a session object.""" + + @abstractmethod + async def update_session(self, session: SessionABC) -> None: + """Update a session in storage. + + This method should be implemented by concrete session services + to persist session changes to their storage backend. + + Args: + session: The session to update + """ + + @abstractmethod + async def create_session_summary(self, session: SessionABC, ctx: "InvocationContext" = None) -> None: + """Summarize a session.""" + + @abstractmethod + async def get_session_summary(self, session: SessionABC) -> Optional[str]: + """Get a summary of a session.""" + + @abstractmethod + async def close(self): + """Closes the session service.""" diff --git a/trpc_agent_sdk/abc/_tool.py b/trpc_agent_sdk/abc/_tool.py new file mode 100644 index 000000000..408a3e9c1 --- /dev/null +++ b/trpc_agent_sdk/abc/_tool.py @@ -0,0 +1,96 @@ +# 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. +""" +TRPC Agent Tool System Base Class. + +This module defines the fundamental ToolABC class that serves as the foundation for all +tools in the TRPC Agent framework. Key aspects include: + +1. Core Functionality: + - Standardized tool interface + - Abstract method contracts + - Tool declaration system + - Request processing pipeline + +2. Key Features: + - Async-first design + - Type-safe tool execution + - LLM request integration + - Support for long-running operations + - Configurable API variants + +3. Implementation Requirements: + - Subclasses must implement run_async() + - Optional _get_declaration() for schema definition + - Optional process_request() for LLM integration + +Example Usage: + class MyTool(ToolABC): + async def run_async(self, *, tool_context, args): + # Tool implementation + return result +""" + +# Standard library imports (system modules) +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Optional +from typing import TYPE_CHECKING + +from ._request import RequestABC + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + + +class ToolABC(ABC): + """Base class for all tools.""" + + def _get_declaration(self) -> Optional[str]: + """Gets the OpenAPI specification of this tool in the form of a FunctionDeclaration. + + NOTE + - Required if subclass uses the default implementation of + `process_request` to add function declaration to LLM request. + - Otherwise, can be skipped + + Returns: + The OpenAPI specification of this tool, or None if it doesn't need to be + added to ModelRequest.config. + """ + return None + + @abstractmethod + async def run_async(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + """Runs the tool with the given arguments and context. + + NOTE + - Required if this tool needs to run at the client side. + - Otherwise, can be skipped + + Args: + args: The LLM-filled arguments. + tool_context: The context of the tool. + + Returns: + The result of running the tool. + """ + + @abstractmethod + async def process_request(self, *, tool_context: InvocationContext, llm_request: RequestABC) -> None: + """Processes the outgoing LLM request for this tool. + + Use cases: + - Most common use case is adding this tool to the LLM request. + - Some tools may just preprocess the LLM request before it's sent out. + + Args: + tool_context: The context of the tool. + llm_request: The outgoing LLM request, mutable this method. + """ diff --git a/trpc_agent_sdk/abc/_toolset.py b/trpc_agent_sdk/abc/_toolset.py new file mode 100644 index 000000000..8818de6c2 --- /dev/null +++ b/trpc_agent_sdk/abc/_toolset.py @@ -0,0 +1,134 @@ +# 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. +""" +TRPC Agent Toolset System Core Abstractions. + +This module defines the fundamental building blocks for creating and managing tool +collections (toolsets) in the TRPC Agent framework. Key components include: + +1. Core Abstractions: + - ToolPredicate: Protocol for dynamic tool filtering + - BaseToolSet: Abstract base class for all toolsets + +2. Key Features: + - Context-aware tool selection + - Runtime protocol checking + - Resource lifecycle management + - Thread-safe tool access + +3. Implementation Patterns: + - Filter tools based on invocation context + - Clean up resources in close() + - Support for both static and dynamic tool collections + +Example Usage: + class MyToolset(BaseToolSet): + async def get_tools(self, context=None): + return [tool1, tool2] if context else [] + + async def close(self): + # Cleanup resources +""" +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Callable +from typing import List +from typing import Optional +from typing import Protocol +from typing import TYPE_CHECKING +from typing import Union +from typing import runtime_checkable + +from ._tool import ToolABC + +if TYPE_CHECKING: + from trpc_agent_sdk.context import InvocationContext + + +@runtime_checkable +class ToolPredicate(Protocol): + """Base class for a predicate that defines the interface to decide whether a + + tool should be exposed to LLM. Toolset implementer could consider whether to + accept such instance in the toolset's constructor and apply the predicate in + get_tools method. + """ + + def __call__(self, tool: ToolABC, invocation_context: Optional[InvocationContext] = None) -> bool: + """Decide whether the passed-in tool should be exposed to LLM based on the + + current context. True if the tool is usable by the LLM. + + It's used to filter tools in the toolset. + """ + + +class ToolSetABC(ABC): + """Base class for toolset. + + A toolset is a collection of tools that can be used by an agent. + """ + """The base class for all tools.""" + + def __init__(self, + *, + tool_filter: Optional[Union[ToolPredicate, List[str]]] = None, + is_include_all_tools: bool = True, + name: str = ''): + self.name = name + self._tool_filter: Optional[Union[ToolPredicate, List[str]]] = tool_filter + self._is_include_all_tools: bool = is_include_all_tools + + def initialize(self) -> None: + """Initialize the toolset.""" + return + + def add_tools(self, tools: list[Union[ToolABC, Callable]]): + """Add tools to the toolset.""" + pass + + def _is_tool_selected(self, tool: ToolABC, invocation_context: InvocationContext | None) -> bool: + """ + Args: + tool: The tool to check. + invocation_context: The invocation context. + + Returns: + True if the tool should be selected, False otherwise. + """ + if not self._tool_filter or self._is_include_all_tools: + return True + + if isinstance(self._tool_filter, ToolPredicate): + return self._tool_filter(tool, invocation_context) + + if isinstance(self._tool_filter, list): + return tool.name in self._tool_filter + + return False + + @abstractmethod + async def get_tools(self, invocation_context: Optional[InvocationContext] = None) -> list[ToolABC]: + """Return all tools in the toolset based on the provided context. + + Args: + readonly_context (AgentContext, optional): Context used to filter tools + available to the agent. If None, all tools in the toolset are returned. + + Returns: + list[ToolABC]: A list of tools available under the specified context. + """ + + async def close(self) -> None: + """Performs cleanup and releases resources held by the toolset. + + NOTE: This method is invoked, for example, at the end of an agent server's + lifecycle or when the toolset is no longer needed. Implementations + should ensure that any open connections, files, or other managed + resources are properly released to prevent leaks. + """ diff --git a/trpc_agent_sdk/agents/__init__.py b/trpc_agent_sdk/agents/__init__.py new file mode 100644 index 000000000..bfc1b9426 --- /dev/null +++ b/trpc_agent_sdk/agents/__init__.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 Apache-2.0. +"""Agent system core components module. + +This module exports the fundamental classes and types required for building +and working with TRPC agents. It provides the base agent class, callback filter, +multi-agent composition patterns, and all essential type definitions for agent development. +""" + +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import new_invocation_context_id + +from ._base_agent import BaseAgent +from ._base_agent import InstructionProvider +from ._callback import AgentCallback +from ._callback import AgentCallbackFilter +from ._callback import CallbackFilter +from ._callback import ModelCallback +from ._callback import ModelCallbackFilter +from ._callback import ToolCallback +from ._callback import ToolCallbackFilter +from ._chain_agent import ChainAgent +from ._cycle_agent import CycleAgent +from ._langgraph_agent import LangGraphAgent +from ._llm_agent import LlmAgent +from ._parallel_agent import ParallelAgent +from ._transfer_agent import TransferAgent +from .core import BranchFilterMode +from .core import TimelineFilterMode +from .utils import get_agent_context +from .utils import get_agent_context as get_langgraph_agent_context +from .utils import get_langgraph_payload +from .utils import langgraph_llm_node +from .utils import langgraph_tool_node + +__all__ = [ + "RunConfig", + "InvocationContext", + "new_invocation_context_id", + "BaseAgent", + "InstructionProvider", + "AgentCallback", + "AgentCallbackFilter", + "ModelCallback", + "ModelCallbackFilter", + "ToolCallback", + "ToolCallbackFilter", + "ChainAgent", + "CycleAgent", + "LangGraphAgent", + "LlmAgent", + "TransferAgent", + "BranchFilterMode", + "TimelineFilterMode", + "ParallelAgent", + "get_agent_context", + "get_langgraph_agent_context", + "langgraph_llm_node", + "langgraph_tool_node", + "CallbackFilter", + "get_langgraph_payload", +] + +# Rebuild Pydantic models to resolve forward references after all imports are complete +InvocationContext.model_rebuild() +LlmAgent.model_rebuild() +ChainAgent.model_rebuild() +ParallelAgent.model_rebuild() +CycleAgent.model_rebuild() +TransferAgent.model_rebuild() diff --git a/trpc_agent_sdk/agents/_base_agent.py b/trpc_agent_sdk/agents/_base_agent.py new file mode 100644 index 000000000..90ecef95d --- /dev/null +++ b/trpc_agent_sdk/agents/_base_agent.py @@ -0,0 +1,343 @@ +# 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. +"""TRPC Agent Base Class Module. + +This module defines the BaseAgent class which serves as the foundation for all +agent implementations in the TRPC Agent Development Kit. + +Key Features: + - Core agent lifecycle management + - Filter pipeline execution + - Context propagation + - Sub-agent hierarchy management + - Callback handling (before/after execution) + +Classes: + BaseAgent: Abstract base class providing core agent functionality +""" + +from __future__ import annotations + +import time +from abc import abstractmethod +from functools import partial +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing import Union +from typing import final +from typing_extensions import override + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.abc import FilterType +from trpc_agent_sdk.code_executors import BaseCodeExecutor +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import create_agent_context +from trpc_agent_sdk.context import reset_invocation_ctx +from trpc_agent_sdk.context import set_invocation_ctx +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.filter import get_filter +from trpc_agent_sdk.filter import run_stream_filters + +from ._callback import AgentCallback +from ._callback import AgentCallbackFilter + +# Type aliases for instruction providers +InstructionProvider = Callable[[InvocationContext], Union[str, Awaitable[str]]] + + +def _aggregate_llm_usage(events: list[Event]) -> tuple[int, int]: + """Sum prompt/completion tokens across LLM events during one agent run. + + Agent-level metrics (``GenAIInvokeAgent``) roll up the token usage of every + LLM call performed during the agent run. Each non-partial ``Event`` produced + by an LLM carries a :class:`GenerateContentResponseUsageMetadata` with the + cumulative counts for that single model call. + + Args: + events: Non-partial events collected during the agent run. + + Returns: + Tuple of ``(input_tokens, output_tokens)``. + """ + input_tokens = 0 + output_tokens = 0 + for event in events: + usage = getattr(event, "usage_metadata", None) + if usage is None: + continue + prompt = getattr(usage, "prompt_token_count", None) or 0 + total = getattr(usage, "total_token_count", None) or 0 + if prompt and total: + input_tokens += prompt + output_tokens += max(total - prompt, 0) + return input_tokens, output_tokens + + +def _build_action_string_from_events(events: list[Event], max_length: int = 500) -> str: + """Build formatted action string from agent events. + + Parses event content to extract and format all actions including: + - Text responses + - Function calls + - Function responses + - Thoughts + + Args: + events: List of non-partial events to process + max_length: Maximum length for function call/response text (default 500) + + Returns: + Formatted string representing all agent actions + """ + action_parts = [] + + for event in events: + if not event.content or not event.content.parts: + continue + + for part in event.content.parts: + # Handle text content + if part.text: + action_parts.append(part.text) + + # Handle thought content + if part.thought: + action_parts.append(f"[Thought: {part.thought}]") + + # Handle function call + if part.function_call: + func_name = part.function_call.name + func_args = str(part.function_call.args) + # Limit function args length + if len(func_args) > max_length: + func_args = func_args[:max_length] + "..." + action_parts.append(f"[Function Call: {func_name}({func_args})]") + + # Handle function response + if part.function_response: + func_name = part.function_response.name + func_response = str(part.function_response.response) + # Limit response length + if len(func_response) > max_length: + func_response = func_response[:max_length] + "..." + action_parts.append(f"[Function Response ({func_name}): {func_response}]") + + return "\n\n".join(action_parts) + + +class BaseAgent(AgentABC): + """Base class for all agents in Agent Development Kit. + + Provides core functionality for agent execution including: + - Filter management and execution + - Asynchronous operation handling + - Context management + - Agent hierarchy management + + Attributes: + name: The agent's name, must be a Python identifier and unique within the agent tree + description: Description about the agent's capability + parent_agent: The parent agent of this agent + sub_agents: The sub-agents of this agent + filters_name: List of filter names that will be applied during agent execution + """ + + before_agent_callback: Optional[AgentCallback] = None + """Callback or list of callbacks to be invoked before the agent run. + + When a list of callbacks is provided, the callbacks will be called in the + order they are listed until a callback does not return None. + + Args: + invocation_context: MUST be named 'invocation_context' (enforced). + + Returns: + Optional[types.Content]: The content to return to the user. + When the content is present, the agent run will be skipped and the + provided content will be returned to user. + """ + after_agent_callback: Optional[AgentCallback] = None + """Callback or list of callbacks to be invoked after the agent run. + + When a list of callbacks is provided, the callbacks will be called in the + order they are listed until a callback does not return None. + + Args: + invocation_context: MUST be named 'invocation_context' (enforced). + + Returns: + Optional[types.Content]: The content to return to the user. + When the content is present, the provided content will be used as agent + response and appended to event history as agent response. + """ + + global_instruction: Union[str, InstructionProvider] = "" + """Instructions for all agents in the entire agent tree. + + ONLY the global_instruction in root agent will take effect. + Used to establish consistent personality or behavior across all agents. + """ + + code_executor: Optional[BaseCodeExecutor] = None + """Allow agent to execute code blocks from model responses using the provided + CodeExecutor. + + Check out available code executions in `trpc_agent_sdk.code_executors` package. + + NOTE: + To use model's built-in code executor, use the `BuiltInCodeExecutor`. + """ + + @override + def get_subagents(self) -> list[AgentABC]: + """Return sub_agents as the list used for lookup. Override in subclasses if needed.""" + return list(self.sub_agents) + + @override + def model_post_init(self, __context: Any) -> None: + """Post init hook for agent.""" + for filter_name in self.filters_name: + filter_instance = get_filter(FilterType.AGENT, filter_name) + if not filter_instance: + raise ValueError(f"Filter {filter_name} not found") + self.filters.append(filter_instance) + self.filters.append(AgentCallbackFilter(self.before_agent_callback, self.after_agent_callback)) + return super().model_post_init(__context) + + def _create_invocation_context(self, parent_context: InvocationContext) -> InvocationContext: + """Creates a new invocation context for this agent.""" + invocation_context = parent_context.model_copy(update={"agent": self}) + + # Handle branch assignment: + # - If parent_context.agent is the same as self, we're being called from runner + # and branch is already set correctly, so don't modify it + # - Otherwise, we're a sub-agent and need to append our name to parent's branch + if parent_context.agent == self: + # Called from runner - branch already set correctly + pass + elif parent_context.branch: + # Sub-agent - append our name to parent's branch + invocation_context.branch = f"{parent_context.branch}.{self.name}" + else: + # Fallback: no branch set, initialize with agent name + invocation_context.branch = self.name + + return invocation_context + + @final + @override + async def run_async( + self, + parent_context: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """Entry point for text-based agent execution. + + Main execution flow: + 1. Setup filters + 2. Create invocation context + 3. Run filters and agent implementation + 4. Yield events + + Args: + parent_context: Context from parent agent with: + - Agent reference + - Invocation ID + - Branch info + + Yields: + Event: Agent output events including: + - Content updates + - State changes + - Actions + """ + from trpc_agent_sdk.telemetry import report_invoke_agent + from trpc_agent_sdk.telemetry import tracer + from trpc_agent_sdk.telemetry import trace_agent + + # Manually propagate span context using attach/detach instead of + # start_as_current_span. This ensures child spans (call_llm, execute_tool, + # etc.) can correctly resolve their parent. + # We use start_span + attach/detach rather than start_as_current_span + # because __aexit__ of the context manager is not guaranteed to run when + # an async generator is cancelled, but try/finally always executes + # even under CancelledError (PEP 492). + with tracer.start_as_current_span(f"agent_run [{self.name}]"): + ctx = self._create_invocation_context(parent_context) + if ctx.agent_context is None: + ctx.agent_context = create_agent_context() + handle = partial(self._run_async_impl, ctx) # type: ignore + token = set_invocation_ctx(ctx) + + # Capture state before agent run + state_begin = dict(ctx.session.state) + + # Track all non-partial events for building action trace + non_partial_events = [] + + mono_start = time.monotonic() + t_first_visible: Optional[float] = None + metrics_error_type: Optional[str] = None + + try: + gen_co = run_stream_filters(ctx.agent_context, None, self.filters, handle) # type: ignore + async for event in gen_co: + if t_first_visible is None and event.has_content(): + t_first_visible = time.monotonic() + if not event.partial and event.content is not None: + # Collect non-partial events with content for tracing + # This excludes state update events which have content=None + non_partial_events.append(event) + yield event # type: ignore + except Exception as ex: + metrics_error_type = type(ex).__name__ + raise + finally: + # Compute state after agent run + state_end = dict(ctx.session.state) + + # Build formatted action string from all non-partial events + agent_action = _build_action_string_from_events(non_partial_events) + + # Call trace function with agent execution details + trace_agent( + invocation_context=ctx, + agent_action=agent_action, + state_begin=state_begin, + state_end=state_end, + ) + + duration_s = time.monotonic() - mono_start + ttft_s = (t_first_visible - mono_start) if t_first_visible is not None else duration_s + input_tokens, output_tokens = _aggregate_llm_usage(non_partial_events) + is_stream = bool(ctx.run_config and ctx.run_config.streaming) + report_invoke_agent( + ctx, + duration_s=duration_s, + ttft_s=ttft_s, + input_tokens=input_tokens, + output_tokens=output_tokens, + is_stream=is_stream, + error_type=metrics_error_type, + ) + + # avoid memory leak + reset_invocation_ctx(token) + + @abstractmethod + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Core logic to run this agent via text-based conversation. + + Args: + ctx: InvocationContext, the invocation context for this agent. + + Yields: + Event: the events generated by the agent. + """ + raise NotImplementedError(f"_run_async_impl for {type(self)} is not implemented.") + # yield # AsyncGenerator requires having at least one yield statement diff --git a/trpc_agent_sdk/agents/_callback.py b/trpc_agent_sdk/agents/_callback.py new file mode 100644 index 000000000..2ef6b7227 --- /dev/null +++ b/trpc_agent_sdk/agents/_callback.py @@ -0,0 +1,321 @@ +# 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. +"""TRPC Agent Callback Management Module. + +This module provides core functionality for managing agent callbacks in TRPC framework, +including pre-invocation (before) and post-invocation (after) callback handlers. + +Key Components: + AgentCallbackFilter: Main filter class for callback processing + Before/After callbacks: Handlers for pre and post agent invocation + Event generation: Creates standardized event objects from callback results + +Features: + - Supports both synchronous and asynchronous callbacks + - Handles callback chaining and execution order + - Generates standardized event objects + - Manages callback context and state +""" +import inspect +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Generic +from typing import Optional +from typing import TypeAlias +from typing import TypeVar +from typing import Union +from typing_extensions import override + +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import get_invocation_ctx +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.filter import FilterResult +from trpc_agent_sdk.filter import FilterType +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import get_tool_var +from trpc_agent_sdk.types import Content + +# Type aliases for callback types +SingleAgentCallback: TypeAlias = Callable[[InvocationContext], Union[Awaitable[Optional[Content]], Optional[Content]]] +AgentCallback: TypeAlias = Union[SingleAgentCallback, list[SingleAgentCallback]] + +# Type aliases for model callback types +SingleModelCallback: TypeAlias = Callable[[InvocationContext, Union[LlmRequest, LlmResponse]], + Union[Awaitable[Optional[LlmResponse]], Optional[LlmResponse]]] +ModelCallback: TypeAlias = Union[SingleModelCallback, list[SingleModelCallback]] + +# Type aliases for tool callback types +SingleToolCallback: TypeAlias = Callable[[InvocationContext, BaseTool, dict[str, Any], dict], + Union[Awaitable[Optional[dict]], Optional[dict]]] +ToolCallback: TypeAlias = Union[SingleToolCallback, list[SingleToolCallback]] + +# Define template type variables for callback types +TCallback = TypeVar('TCallback') + + +class CallbackFilter(BaseFilter, Generic[TCallback]): + """Filter for handling agent callback operations (generic version).""" + + def __init__(self, filter_type: FilterType, name: str, before_callback: Union[TCallback, list[TCallback]], + after_callback: Union[TCallback, list[TCallback]]): + super().__init__() + self._type = filter_type + self._name = name + self._before_callback: list[TCallback] = self.canonical_callbacks(before_callback) + self._after_callback: list[TCallback] = self.canonical_callbacks(after_callback) + + @staticmethod + def canonical_callbacks(callback: Union[TCallback, list[TCallback]]) -> list[TCallback]: + if not callback: + return [] + if isinstance(callback, list): + return callback + return [callback] + + +class AgentCallbackFilter(CallbackFilter[SingleAgentCallback]): + """Filter for handling agent callback operations. + + This filter manages both pre-invocation (before) and post-invocation (after) callbacks + for agent operations. It ensures proper execution order and handles both synchronous + and asynchronous callback functions. + """ + + def __init__(self, before_callback: Union[SingleAgentCallback, list[SingleAgentCallback]], + after_callback: Union[SingleAgentCallback, list[SingleAgentCallback]]): + super().__init__(FilterType.AGENT, "agent_callback", before_callback, after_callback) + + @override + async def _before(self, ctx: AgentContext, _: Any, rsp: FilterResult): + """Execute pre-invocation callbacks. + + Processes all registered before-agent callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Stops processing if any callback + returns content or sets end_invocation flag. + + Args: + ctx: Invocation context containing agent and request info + req: The request string to process + + Returns: + FilterResult containing event data if callbacks produced output, + None otherwise + """ + if not self._before_callback: + return + invocation_ctx: InvocationContext = get_invocation_ctx() + agent_name = invocation_ctx.agent.name + for callback in self._before_callback: + before_agent_callback_content = callback(invocation_ctx) + if inspect.isawaitable(before_agent_callback_content): + before_agent_callback_content = await before_agent_callback_content + if before_agent_callback_content: + ret_event = Event( + invocation_id=invocation_ctx.invocation_id, + author=agent_name, + branch=invocation_ctx.branch, + content=before_agent_callback_content, + actions=invocation_ctx.event_actions, + ) + invocation_ctx.end_invocation = True + rsp.rsp = ret_event + rsp.error = None + rsp.is_continue = False + return + if invocation_ctx.state.has_delta(): + ret_event = Event( + invocation_id=invocation_ctx.invocation_id, + author=agent_name, + branch=invocation_ctx.branch, + actions=invocation_ctx.event_actions, + ) + rsp.rsp = ret_event + rsp.error = None + rsp.is_continue = True + return + + @override + async def _after(self, ctx: AgentContext, _: Any, rsp: FilterResult): + """Execute post-invocation callbacks. + + Processes all registered after-agent callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Collects output from all callbacks. + + Args: + ctx: Invocation context containing agent and request info + req: The request string that was processed + + Returns: + FilterResult containing aggregated event data from callbacks, + None if no callbacks were registered + """ + if not self._after_callback: + return # type: ignore + ret = None + invocation_ctx: InvocationContext = get_invocation_ctx() + agent_name = invocation_ctx.agent.name + for callback in self._after_callback: + after_agent_callback_content = callback(invocation_ctx) + if inspect.isawaitable(after_agent_callback_content): + after_agent_callback_content = await after_agent_callback_content + if after_agent_callback_content: + ret_event = Event( + invocation_id=invocation_ctx.invocation_id, + author=agent_name, + branch=invocation_ctx.branch, + content=after_agent_callback_content, + actions=invocation_ctx.event_actions, + ) + rsp.rsp = ret_event + return + if invocation_ctx.state.has_delta(): + ret_event = Event( + invocation_id=invocation_ctx.invocation_id, + author=agent_name, + branch=invocation_ctx.branch, + content=after_agent_callback_content, + actions=invocation_ctx.event_actions, + ) + rsp.rsp = ret_event + return + + return ret + + +class ModelCallbackFilter(CallbackFilter[SingleModelCallback]): + """Filter for handling model callback operations. + + This filter manages both pre-invocation (before) and post-invocation (after) callbacks + for model operations. It ensures proper execution order and handles both synchronous + and asynchronous callback functions. + """ + + def __init__(self, before_callback: Union[SingleModelCallback, list[SingleModelCallback]], + after_callback: Union[SingleModelCallback, list[SingleModelCallback]]): + super().__init__(FilterType.MODEL, "model_callback", before_callback, after_callback) + + @override + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Execute pre-invocation callbacks. + + Processes all registered before-model callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Stops processing if any callback + returns content or sets end_invocation flag. + + Args: + ctx: Invocation context containing model and request info + req: The request string to process + + Returns: + FilterResult containing event data if callbacks produced output, + None otherwise + """ + if not self._before_callback: + return + invocation_ctx: InvocationContext = get_invocation_ctx() + for callback in self._before_callback: + before_model_callback_content = callback(invocation_ctx, req) + if inspect.isawaitable(before_model_callback_content): + before_model_callback_content = await before_model_callback_content + if before_model_callback_content: + invocation_ctx.end_invocation = True + rsp.rsp = before_model_callback_content + rsp.is_continue = False + rsp.error = None + return + + @override + async def _after_every_stream(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + """Execute post-invocation callbacks for every stream. + + Processes all registered after-agent callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Collects output from all callbacks. + + Args: + ctx: Invocation context containing agent and request info + rsp: The filter result + + Returns: + None + """ + if not self._after_callback: + return # type: ignore + invocation_ctx: InvocationContext = get_invocation_ctx() + for callback in self._after_callback: + after_model_callback_content = callback(invocation_ctx, rsp.rsp) # type: ignore + if inspect.isawaitable(after_model_callback_content): + after_model_callback_content = await after_model_callback_content + if after_model_callback_content: + rsp.rsp = after_model_callback_content + return + + +class ToolCallbackFilter(CallbackFilter[SingleToolCallback]): + """Filter for handling tool callback operations. + + This filter manages both pre-invocation (before) and post-invocation (after) callbacks + for tool operations. It ensures proper execution order and handles both synchronous + and asynchronous callback functions. + """ + + def __init__(self, before_callback: Union[SingleToolCallback, list[SingleToolCallback]], + after_callback: Union[SingleToolCallback, list[SingleToolCallback]]): + super().__init__(FilterType.TOOL, "tool_callback", before_callback, after_callback) + + @override + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Execute pre-invocation callbacks. + + Processes all registered before-tool callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Stops processing if any callback + returns content or sets end_invocation flag. + + Args: + ctx: Invocation context containing tool and request info + req: The request string to process + + Returns: + FilterResult containing event data if callbacks produced output, + None otherwise + """ + if not self._before_callback: + return + invocation_ctx: InvocationContext = get_invocation_ctx() + tool = get_tool_var() + for callback in self._before_callback: + before_tool_callback_content = callback(invocation_ctx, tool, req, None) # type: ignore + if inspect.isawaitable(before_tool_callback_content): + before_tool_callback_content = await before_tool_callback_content + if before_tool_callback_content: + rsp.rsp = before_tool_callback_content + rsp.is_continue = False + rsp.error = None + await self._after(ctx, req, rsp) + return + + @override + async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Execute post-invocation callbacks. + + Processes all registered after-tool callbacks in sequence. Handles both + synchronous and asynchronous callbacks. Collects output from all callbacks. + """ + if not self._after_callback: + return + invocation_ctx: InvocationContext = get_invocation_ctx() + tool = get_tool_var() + for callback in self._after_callback: + after_tool_callback_content = callback(invocation_ctx, tool, req, rsp.rsp) # type: ignore + if inspect.isawaitable(after_tool_callback_content): + after_tool_callback_content = await after_tool_callback_content + if after_tool_callback_content: + rsp.rsp = after_tool_callback_content + return diff --git a/trpc_agent_sdk/agents/_chain_agent.py b/trpc_agent_sdk/agents/_chain_agent.py new file mode 100644 index 000000000..53f5dd625 --- /dev/null +++ b/trpc_agent_sdk/agents/_chain_agent.py @@ -0,0 +1,62 @@ +# 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. +"""Chain Agent Implementation. + +This module provides the ChainAgent class which executes sub-agents sequentially, +one after another. This is useful for creating workflows where agents need to +process results from previous agents in order. + +Classes: + ChainAgent: A shell agent that runs its sub-agents in sequence +""" + +from __future__ import annotations + +from typing import AsyncGenerator +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event + +from ._base_agent import BaseAgent + + +class ChainAgent(BaseAgent): + """A shell agent that runs its sub-agents in sequence. + + The ChainAgent executes each sub-agent one after another, allowing + subsequent agents to process and build upon the outputs of previous agents. + This is ideal for creating sequential workflows where the order of execution + matters. + + Example: + ```python + chain_agent = ChainAgent( + name="data_processing_chain", + description="Process data through multiple steps", + sub_agents=[ + data_collection_agent, + data_validation_agent, + data_analysis_agent, + report_generation_agent + ] + ) + ``` + """ + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Execute sub-agents sequentially. + + Args: + ctx: The invocation context for this agent + + Yields: + Event: Events generated by each sub-agent in sequence + """ + for sub_agent in self.sub_agents: + async for event in sub_agent.run_async(ctx): + yield event diff --git a/trpc_agent_sdk/agents/_constants.py b/trpc_agent_sdk/agents/_constants.py new file mode 100644 index 000000000..00af92752 --- /dev/null +++ b/trpc_agent_sdk/agents/_constants.py @@ -0,0 +1,48 @@ +# 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. +"""Agent System Constants Definition Module. + +This module centralizes all agent-related constants following these principles: +1. Group related constants logically +2. Provide clear documentation for each constant +3. Maintain consistent naming conventions +4. Support both direct usage and type checking + +Constants are categorized into: +- Filter naming conventions +- Model configuration defaults +- Type system mappings +- Context storage keys +""" + +# Number of parts to split filter names into +FILTER_NAME_SPLIT_NUM = 2 +"""Number of segments expected when parsing hierarchical filter names.""" + +MODEL_NAME = "model_name" +"""Default model name used by the agent.""" + +TOOL_CALL_INFO = "tool_call_info" +"""Key for storing tool call information in the context.""" + +TYPE_LABELS = { + "STRING": "string", + "NUMBER": "number", + "BOOLEAN": "boolean", + "OBJECT": "object", + "ARRAY": "array", + "INTEGER": "integer", +} +"""Canonical type labels for agent type system validation. + +Maps internal type identifiers to standardized type names used in: +- API documentation +- Error messages +- Schema validation +""" + +TRPC_AGENT_RUNNING_KEY = "__trpc_agent__running" +"""Key for storing the running state of the agent.""" diff --git a/trpc_agent_sdk/agents/_cycle_agent.py b/trpc_agent_sdk/agents/_cycle_agent.py new file mode 100644 index 000000000..9e6156dc8 --- /dev/null +++ b/trpc_agent_sdk/agents/_cycle_agent.py @@ -0,0 +1,78 @@ +# 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. +"""Cycle Agent Implementation. + +This module provides the CycleAgent class which executes sub-agents in a loop +until escalation or max_iterations, similar to ADK's LoopAgent. + +Classes: + CycleAgent: A shell agent that runs its sub-agents in a cycle/loop +""" + +from __future__ import annotations + +from typing import AsyncGenerator +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event + +from ._base_agent import BaseAgent + + +class CycleAgent(BaseAgent): + """A shell agent that runs its sub-agents in a loop. + + When a sub-agent generates an event with escalate flag set or max_iterations + are reached, the cycle agent will stop. + + This is useful for: + - Iterative refinement processes + - Continuous monitoring and adjustment + - Multi-round negotiation or problem-solving + + Example: + ```python + cycle_agent = CycleAgent( + name="iterative_solver", + description="Iteratively solve problems until convergence", + max_iterations=5, + sub_agents=[ + analysis_agent, + solution_agent, + validation_agent + ] + ) + ``` + """ + + max_iterations: Optional[int] = None + """The maximum number of iterations to run the cycle agent. + + If not set, the cycle agent will run indefinitely until a sub-agent + escalates. + """ + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Execute sub-agents in a loop until escalation or max iterations. + + Args: + ctx: The invocation context for this agent + + Yields: + Event: Events generated by sub-agents in each iteration + """ + times_looped = 0 + while not self.max_iterations or times_looped < self.max_iterations: + for sub_agent in self.sub_agents: + async for event in sub_agent.run_async(ctx): + yield event + if ctx.actions.escalate: + return + times_looped += 1 + return diff --git a/trpc_agent_sdk/agents/_langgraph_agent.py b/trpc_agent_sdk/agents/_langgraph_agent.py new file mode 100644 index 000000000..019832b7b --- /dev/null +++ b/trpc_agent_sdk/agents/_langgraph_agent.py @@ -0,0 +1,823 @@ +# 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. +# +"""LangGraph Agent for TRPC framework. + +This agent integrates LangGraph's streaming capabilities with the TRPC Agent framework, +supporting all stream modes: values, updates, custom, messages, and debug. +""" + +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import Optional +from typing import Union +from typing_extensions import override + +from google.genai import types +from langchain_core.messages import AIMessage +from langchain_core.messages import AIMessageChunk +from langchain_core.messages import HumanMessage +from langchain_core.messages import SystemMessage +from langchain_core.messages import ToolMessage +from langchain_core.messages import convert_to_messages +from langchain_core.messages.tool import ToolCall +from langchain_core.runnables.config import RunnableConfig +from langgraph.graph.state import CompiledStateGraph +from langgraph.types import Command +from pydantic import BaseModel +from pydantic import ConfigDict + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse + +from ..exceptions import RunCancelledException +from ._base_agent import BaseAgent +from .utils import AGENT_CTX_KEY +from .utils import CHUNK_KEY +from .utils import LANGGRAPH_KEY +from .utils import STREAM_MODE_KEY +from .utils import TRPC_AGENT_KEY +from .utils import extract_trpc_event +from .utils import is_trpc_event_chunk + +# LangGraph interrupt constant +_INTERRUPT_KEY: str = "__interrupt__" + +# TRPC long running prefix constant +_TRPC_LONG_RUNNING_PREFIX: str = "__trpc_long_running__" + + +class LangGraphAgent(BaseAgent): + """LangGraph Agent with streaming support for TRPC framework. + + This agent integrates LangGraph's streaming capabilities with the TRPC Agent framework, + supporting all stream modes: values, updates, custom, messages, and debug. + + Configuration via RunConfig: + - stream_mode: List of stream modes to enable (extends default: updates, custom, messages) + - runnable_config: LangChain RunnableConfig for the graph execution + - input: Custom input dictionary to merge with the default {"messages": []} input + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, ) + """The pydantic model config.""" + + graph: CompiledStateGraph + """The compiled LangGraph state graph.""" + + instruction: str = "" + """Instructions for the agent.""" + + output_key: Optional[str] = None + """Key in session state to store agent output for later use.""" + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + + # CHECKPOINT 1: At method entry + await ctx.raise_if_cancelled() + + # Parse agent configuration + config = self._parse_agent_config(ctx) + stream_modes = config[STREAM_MODE_KEY] + runnable_config = config["runnable_config"] + custom_input = config["input"] + subgraphs = config.get("subgraphs", False) + + # Check if we have a resume command from events + resume_command = self._extract_resume_command(ctx.session.events) + + if not resume_command: + # Build messages when no resume command is present + messages = self._build_messages(ctx, runnable_config) + logger.debug("messages: %s", messages) + # Prepare input for astream - merge messages with custom input + astream_input = {"messages": messages} + if custom_input: + # Merge custom input with default messages input + astream_input.update(custom_input) + logger.debug("Custom input provided: %s", custom_input) + else: + # Use the Command directly for astream + astream_input = resume_command + logger.debug("Using resume command: %s", resume_command) + + try: + async for chunk in self.graph.astream(astream_input, + runnable_config, + stream_mode=stream_modes, + subgraphs=subgraphs): + # Handle subgraphs output format: + # - subgraphs=True + multiple stream_modes: (namespace, stream_mode, data) - 3-tuple + # - subgraphs=False + multiple stream_modes: (stream_mode, data) - 2-tuple + if subgraphs: + namespace, stream_mode, chunk_data = chunk + logger.debug("subgraph namespace: %s, stream_mode: %s, chunk: %s", namespace, stream_mode, + chunk_data) + else: + stream_mode, chunk_data = chunk + namespace = () + logger.debug("stream_mode: %s, chunk: %s", stream_mode, chunk_data) + + # Check for interrupt in chunk + if self._check_for_interrupt_in_chunk(chunk_data): + interrupt_data = chunk_data[_INTERRUPT_KEY] + for interrupt in interrupt_data: + await ctx.raise_if_cancelled() + + # Create all interrupt-related events + logger.debug("get interrupt: %s", interrupt) + fc_call_event, fc_rsp_event, long_running_event = self._create_interrupt_events( + ctx, interrupt, stream_mode, chunk_data) + logger.debug("getting LongRunningEvent: %s", long_running_event) + + # Yield the events in order + yield fc_call_event + yield fc_rsp_event + yield long_running_event + continue + + if stream_mode == "messages": + # Handle LLM token streaming - chunk is (token, metadata) + token, _ = chunk_data + # Only yield AIMessageChunk. Other type messages should be raised by updates + if not isinstance(token, AIMessageChunk): + continue + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=types.Content( + role="model", + parts=[types.Part.from_text(text=token.content)], + ), + custom_metadata=self._build_custom_metadata(stream_mode, chunk_data), + partial=True, + ) + + yield event + elif stream_mode == "updates": + # Handle complete message updates - these are partial=False + # When subgraphs=True and namespace is empty, this is a parent graph node update + # The parent node's messages contain all accumulated messages from subgraph, + # which have already been emitted by subgraph internal nodes. + # Skip emitting messages from parent node to avoid duplicates. + if subgraphs and not namespace: + # Parent graph node update - only emit custom_metadata, skip messages + logger.debug("Skipping messages from parent node to avoid duplicates") + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + custom_metadata=self._build_custom_metadata(stream_mode, chunk_data), + partial=True, + ) + yield event + continue + for _, node_data in chunk_data.items(): + # Skip if node_data when node returns empty dict or None + if not node_data: + continue + if "messages" in node_data: + for message in node_data["messages"]: + event = self._build_event_from_message(ctx, message, stream_mode, chunk_data) + if event: + # Save output to state if this is a final response + if event.is_final_response(): + self._save_output_to_state(ctx, event) + yield event + else: + # Handle other stream modes (custom, debug) + # NEW: Check if this is a trpc Event from a node using LangGraphEventWriter + if is_trpc_event_chunk(chunk_data): + trpc_event = extract_trpc_event(chunk_data) + yield trpc_event + else: + # Existing behavior: wrap in custom_metadata + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + custom_metadata=self._build_custom_metadata(stream_mode, chunk_data), + partial=True, + ) + yield event + + # CHECKPOINT 2: At each chunk iteration + await ctx.raise_if_cancelled() + except RunCancelledException: + # Re-raise to let Runner handle cleanup + raise + + def _apply_template_substitution(self, instruction: str, ctx: InvocationContext) -> str: + """Apply template substitution to replace {key} placeholders with state values. + + This method replaces template placeholders like {user:theme}, {app:config}, + {session_key}, etc. with actual values from the session state. + + This implementation matches the one in _request_processor.py to provide + consistent template substitution behavior across agent types. + + Args: + instruction: The instruction string containing template placeholders + ctx: The invocation context with session state + + Returns: + str: The instruction with template placeholders replaced with actual values + """ + if not instruction or "{" not in instruction: + return instruction + + # Get all state values from the session + state_dict = ctx.session.state if ctx.session else {} + + try: + # Use regex to find and replace placeholders one by one + import re + + def replace_placeholder(match): + """Replace a single placeholder with its value.""" + var_name = match.group().lstrip("{").rstrip("}").strip() + optional = False + + # Handle optional variables (ending with ?) + if var_name.endswith("?"): + optional = True + var_name = var_name.removesuffix("?") + + # Check if the variable exists in state + if var_name in state_dict: + # Convert the value to string safely + value = state_dict[var_name] + return str(value) if value is not None else "" + else: + if optional: + # Optional variable not found - return empty string + return "" + else: + # Required variable not found - leave placeholder unchanged + # This follows the behavior of the original SafeFormatter approach + return match.group() + + # Use regex pattern similar to adk-python but simpler for trpc_agent_sdk + # This matches {variable_name} patterns including optional ones with ? + pattern = r"\{[^{}]*\}" + result = re.sub(pattern, replace_placeholder, instruction) + + logger.debug("Template substitution completed. Original: %s..., Result: %s...", instruction[:100], + result[:100]) + return result + except Exception as ex: # pylint: disable=broad-except + logger.warning("Template substitution failed for instruction: %s", ex) + # Return original instruction if formatting fails + return instruction + + def _save_output_to_state(self, ctx: InvocationContext, event: Event) -> None: + """Save agent output to session state if output_key is configured. + + Args: + ctx: The invocation context + event: The event containing the content to save + """ + if self.output_key and event.content and event.content.parts: + # Save output to state using the delta tracking system + result = "".join([part.text for part in event.content.parts if part.text]) + if result: # Only save non-empty results + ctx.state[self.output_key] = result + event.actions.state_delta[self.output_key] = result + logger.debug("Saved agent output to state key '%s': %s...", self.output_key, result[:100]) + + def _build_custom_metadata(self, stream_mode: str, chunk: Any) -> Dict[str, Any]: + """Build custom metadata structure for LangGraph events. + + Args: + stream_mode: The LangGraph stream mode + chunk: The chunk data from LangGraph + + Returns: + Dictionary with nested structure containing stream mode and chunk + """ + # Serialize Message objects in 'updates' mode to avoid JSON serialization errors + if stream_mode == "updates": + for node_output in chunk.values(): + if isinstance(node_output, dict) and "messages" in node_output: + # Convert LangChain Message objects to dictionaries for JSON compatibility + message_objects = node_output.pop("messages", []) + messages_res = [] + for msg in message_objects: + if isinstance(msg, BaseModel): + messages_res.append(msg.model_dump_json()) + else: + messages_res.append(msg) + node_output["messages"] = messages_res + + return {LANGGRAPH_KEY: {STREAM_MODE_KEY: stream_mode, CHUNK_KEY: chunk}} + + def _check_for_interrupt_in_chunk(self, chunk: Any) -> bool: + """Check if a chunk contains interrupt data. + + Args: + chunk: The chunk data from LangGraph streaming + + Returns: + True if the chunk contains interrupt data, False otherwise + """ + # Check if chunk has __interrupt__ key with tuple containing Interrupt object + if isinstance(chunk, dict) and _INTERRUPT_KEY in chunk: + interrupt_data = chunk[_INTERRUPT_KEY] + # Check if it's a tuple containing an Interrupt object + if isinstance(interrupt_data, tuple) and len(interrupt_data) > 0: + return True + return False + + def _create_interrupt_events(self, ctx: InvocationContext, interrupt, stream_mode: str, + chunk: Any) -> tuple[Event, Event, LongRunningEvent]: + """Create function call, function response, and long running events for an interrupt. + + Args: + ctx: The invocation context + interrupt: The LangGraph interrupt object + stream_mode: The current stream mode + chunk: The chunk data from LangGraph + + Returns: + Tuple of (function_call_event, function_response_event, long_running_event) + """ + # Extract ID and function name from interrupt namespace + id = interrupt.ns[0] + func_name = id.split(":")[0] + + # Create a synthetic function call for the interrupt + function_call = FunctionCall( + id=f"{_TRPC_LONG_RUNNING_PREFIX}{id}", + name=func_name, + args=interrupt.value, + ) + + # Create Event for function call + function_call_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=types.Content( + role="model", + parts=[types.Part(function_call=function_call)], + ), + custom_metadata=self._build_custom_metadata(stream_mode, chunk), + partial=False, + ) + + function_response = FunctionResponse( + id=function_call.id, + name=function_call.name, + response=interrupt.value, + ) + + # Create Event for function response + function_response_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=types.Content( + role="user", + parts=[types.Part(function_response=function_response)], + ), + custom_metadata=self._build_custom_metadata(stream_mode, chunk), + partial=False, + ) + + # Create LongRunningEvent + long_running_event = LongRunningEvent( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + function_call=function_call, + function_response=function_response, + ) + + return function_call_event, function_response_event, long_running_event + + def _extract_resume_command(self, events: list[Event]) -> Optional[Command]: + """Extract resume command from events if present. + + Looks for function responses with TRPC long running prefix and converts + them to LangGraph resume commands. + + Args: + events: List of events to search for resume commands + + Returns: + Resume Command if found, None otherwise + """ + # Must use checkpointer to resume + if not self.graph.checkpointer or len(events) == 0: + return None + last_event = events[-1] + if not last_event: + return None + if last_event.author == "user" and last_event.content and last_event.content.parts: + part = last_event.content.parts[0] + fc_rsp = part.function_response + if fc_rsp and fc_rsp.id and fc_rsp.id.startswith(_TRPC_LONG_RUNNING_PREFIX): + return Command(resume=fc_rsp.response) + return None + + def _build_messages(self, ctx: InvocationContext, runnable_config: RunnableConfig) -> list: + """Build messages for LangGraph when no resume command is present. + + If ctx.override_messages is set (e.g., by TeamAgent for member control), + those messages are used directly instead of building from session events. + + Args: + ctx: The agent context + runnable_config: The runnable configuration + + Returns: + List of messages to send to LangGraph + """ + messages = [] + + # Add instruction as SystemMessage if graph state is empty, with template substitution + if self.graph.checkpointer: + current_graph_state = self.graph.get_state(runnable_config) + graph_messages = current_graph_state.values.get("messages", []) if current_graph_state.values else [] + if self.instruction and not graph_messages: + # Apply template substitution to instruction + processed_instruction = self._apply_template_substitution(self.instruction, ctx) + messages = [SystemMessage(content=processed_instruction)] + else: + messages = [] + else: + if self.instruction: + # Apply template substitution to instruction + processed_instruction = self._apply_template_substitution(self.instruction, ctx) + messages = [SystemMessage(content=processed_instruction)] + else: + messages = [] + + # Check if override_messages is provided (e.g., by TeamAgent) + if ctx.override_messages is not None: + # Convert override_messages (Content objects) to LangChain messages + messages += self._convert_override_messages_to_langchain(ctx.override_messages) + print(f"LangGraph agent {self.name} using override_messages: {ctx.override_messages}") + logger.debug("Used %s override messages for LangGraph agent: %s", len(ctx.override_messages), self.name) + else: + # Add events to messages (evaluating the memory used; parent agent vs checkpointer) + messages += self._get_messages(ctx.session.events) + return messages + + def _convert_override_messages_to_langchain(self, override_messages: list) -> list: + """Convert override_messages (Content objects) to LangChain messages. + + Args: + override_messages: List of Content objects from TeamAgent + + Returns: + List of LangChain messages (HumanMessage, AIMessage, ToolMessage) + """ + from trpc_agent_sdk.types import Content + langchain_messages = [] + + for content in override_messages: + if not isinstance(content, Content) or not content.parts: + continue + + role = content.role or "user" + + for part in content.parts: + if part.text: + if role == "user": + langchain_messages.append(HumanMessage(content=part.text)) + else: + langchain_messages.append(AIMessage(content=part.text)) + elif part.function_call: + # Convert to AIMessage with tool_calls + tool_call = ToolCall( + name=part.function_call.name, + args=part.function_call.args or {}, + id=part.function_call.id or f"call_{hash(part.function_call.name)}", + ) + langchain_messages.append(AIMessage(content="", tool_calls=[tool_call])) + elif part.function_response: + # Convert to ToolMessage + response_content = part.function_response.response + if isinstance(response_content, dict): + content_str = response_content.get("result", str(response_content)) + else: + content_str = str(response_content) if response_content else "" + + tool_call_id = part.function_response.id or f"call_{hash(part.function_response.name)}" + langchain_messages.append( + ToolMessage(content=content_str, name=part.function_response.name, tool_call_id=tool_call_id)) + + return langchain_messages + + def _parse_agent_config(self, ctx: InvocationContext) -> Dict[str, Any]: + """Parse agent configuration and return stream mode, runnable config, and input. + + Args: + ctx: The invocation context + + Returns: + Dictionary containing 'stream_mode', 'runnable_config', 'input', and 'subgraphs' keys + """ + # Configure stream modes (can be overridden by agent_run_config) + if STREAM_MODE_KEY in ctx.run_config.agent_run_config: + # If stream_mode is explicitly configured, use it (override default) + stream_mode = list(ctx.run_config.agent_run_config[STREAM_MODE_KEY]) + else: + # Default stream modes + stream_mode = [ + "updates", + "custom", + "messages", + ] + + # Configure runnable config + if "runnable_config" in ctx.run_config.agent_run_config: + runnable_config = ctx.run_config.agent_run_config["runnable_config"] + else: + runnable_config: RunnableConfig = {"configurable": {"thread_id": ctx.session.id}} + runnable_config[TRPC_AGENT_KEY] = {AGENT_CTX_KEY: ctx} + + # Extract custom input from agent_run_config + custom_input = ctx.run_config.agent_run_config.get("input", {}) + + # Extract subgraphs config (default False for backward compatibility) + subgraphs = ctx.run_config.agent_run_config.get("subgraphs", False) + + return { + STREAM_MODE_KEY: stream_mode, + "runnable_config": runnable_config, + "input": custom_input, + "subgraphs": subgraphs + } + + def _build_event_from_message(self, ctx: InvocationContext, message, stream_mode: str, + chunk: Any) -> Optional[Event]: + """Build an Event from a LangChain message. + + Args: + ctx: The invocation context + message: The LangChain message (AIMessage or ToolMessage) + stream_mode: The stream mode + chunk: The chunk data + + Returns: + Event or None if message type is not supported + """ + if isinstance(message, AIMessage): + # Handle AIMessage - could be regular text or tool calls + parts = [] + usage_metadata = None + + # Extract usage metadata if available + if hasattr(message, "usage_metadata") and message.usage_metadata: + usage_data = message.usage_metadata + usage_metadata = types.GenerateContentResponseUsageMetadata( + prompt_token_count=usage_data.get("input_tokens", 0), + candidates_token_count=usage_data.get("output_tokens", 0), + total_token_count=usage_data.get("total_tokens", 0), + ) + + # Check if this is a tool call message + if hasattr(message, "tool_calls") and message.tool_calls: + # Build function call parts for each tool invocation + tool_call_parts = [] + for tool_call in message.tool_calls: + # Create FunctionCall with all parameters in constructor for reliability + function_call = types.FunctionCall( + id=tool_call["id"], + name=tool_call["name"], + args=tool_call["args"], + ) + tool_call_parts.append(types.Part(function_call=function_call)) + parts.extend(tool_call_parts) + elif message.content: + # This is a regular text message + parts.append(types.Part.from_text(text=message.content)) + + if parts: + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=types.Content( + role="model", + parts=parts, + ), + usage_metadata=usage_metadata, + custom_metadata=self._build_custom_metadata(stream_mode, chunk), + partial=False, # Complete message + ) + + return event + + elif isinstance(message, ToolMessage): + # Handle ToolMessage - function response + # Try to parse content as structured data, fallback to simple result wrapper + try: + import json + + # Attempt to parse JSON string content + parsed_content = json.loads(message.content) if isinstance(message.content, str) else message.content + + # Ensure response data is a dictionary (wrap non-dict types) + if isinstance(parsed_content, dict): + response_data = parsed_content + else: + response_data = {"result": parsed_content} + + except (json.JSONDecodeError, TypeError): + # Fallback: wrap unparsable content in result field + response_data = {"result": message.content} + + # Create FunctionResponse with all parameters in constructor + function_response = types.FunctionResponse( + id=message.tool_call_id, + name=message.name, + response=response_data, + ) + parts = [types.Part(function_response=function_response)] + + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=types.Content( + role="model", + parts=parts, + ), + custom_metadata=self._build_custom_metadata(stream_mode, chunk), + partial=False, # Complete message + ) + + return event + + return None + + def _get_last_human_messages(self, events: list[Event]) -> list[HumanMessage]: + """Extracts last human messages from given list of events. + + Only processes text messages. Function responses with TRPC long running prefix + are handled separately by _extract_resume_command. + + Args: + events: the list of events + + Returns: + list of last human messages + """ + messages = [] + for event in reversed(events): + if messages and event.author != "user": + break + if event.author == "user" and event.content and event.content.parts: + part = event.content.parts[0] + if part.text: + # Regular text message + messages.append(HumanMessage(content=part.text)) + break + else: + raise ValueError(f"Invalid message part: {part}") + return list(messages) + + def _get_messages(self, events: list[Event]) -> list[Union[HumanMessage, AIMessage, ToolMessage]]: + """Extracts messages from given list of events. + + If the developer provides their own memory within langgraph, we return the + last user messages only. Otherwise, we return all messages between the user + and the agent. + + Args: + events: the list of events + + Returns: + list of messages + """ + if self.graph.checkpointer: + return self._get_last_human_messages(events) + else: + return self._get_conversation_with_agent(events) + + def _get_conversation_with_agent(self, events: list[Event]) -> list[Union[HumanMessage, AIMessage, ToolMessage]]: + """Extracts messages from given list of events. + + Args: + events: the list of events + + Returns: + list of messages + """ + + messages = [] + for event in events: + if not event.content or not event.content.parts: + continue + + if event.author == "user": + # User messages are always text + if event.content.parts[0].text: + messages.append(HumanMessage(content=event.content.parts[0].text)) + elif event.author == self.name: + # Agent messages can be text, function calls, or function responses + converted_messages = self._convert_event_to_message(event) + if converted_messages: + messages.extend(converted_messages) + return messages + + def _convert_event_to_message(self, event: Event) -> list[Union[AIMessage, ToolMessage]]: + """Convert an Event back to LangChain messages. + + Args: + event: The Event to convert + + Returns: + List of LangChain messages (AIMessage for text/function calls, ToolMessage for function responses) + """ + if not event.content or not event.content.parts: + return [] + + # First try to extract from LangGraph metadata + langgraph_messages = self._extract_messages_from_langgraph_metadata(event) + if langgraph_messages: + return langgraph_messages + + # Fallback to converting from parts + return self._convert_parts_to_messages(event) + + def _extract_messages_from_langgraph_metadata(self, event: Event) -> list[Union[AIMessage, ToolMessage]]: + """Extract and clean messages from LangGraph custom metadata. + + Args: + event: The Event with potential LangGraph metadata + + Returns: + List of cleaned LangChain messages, or empty list if no metadata found + """ + if not (event.custom_metadata and LANGGRAPH_KEY in event.custom_metadata): + return [] + + # Extract chunk data from metadata + langgraph_metadata = event.custom_metadata[LANGGRAPH_KEY] + chunk_data = langgraph_metadata[CHUNK_KEY] + + # Search for messages in any node output + for node_output in chunk_data.values(): + if isinstance(node_output, dict) and "messages" in node_output: + # Convert serialized message dicts back to Message objects + serialized_messages = node_output["messages"] + return convert_to_messages(serialized_messages) + + return [] + + def _convert_parts_to_messages(self, event: Event) -> list[Union[AIMessage, ToolMessage]]: + """Convert event parts to LangChain messages. + + Args: + event: The Event to convert + + Returns: + List of LangChain messages + """ + messages = [] + + for part in event.content.parts: + if part.function_call: + logger.debug("build function_call message with custom_metadata: %s", event.custom_metadata) + function_call = ToolCall( + name=part.function_call.name, + args=part.function_call.args, + id=part.function_call.id, + ) + ai_message = AIMessage(content="", tool_calls=[function_call]) + messages.append(ai_message) + + elif part.function_response: + logger.debug("build function_response message with custom_metadata: %s", event.custom_metadata) + response_content = part.function_response.response + if isinstance(response_content, dict): + content = response_content.get("result", str(response_content)) + else: + content = str(response_content) + + tool_call_id = part.function_response.id + if not tool_call_id: + tool_call_id = f"unknown_{hash(part.function_response.name)}" + + tool_message = ToolMessage(content=content, name=part.function_response.name, tool_call_id=tool_call_id) + messages.append(tool_message) + + elif part.text: + ai_message = AIMessage(content=part.text) + messages.append(ai_message) + + return messages diff --git a/trpc_agent_sdk/agents/_llm_agent.py b/trpc_agent_sdk/agents/_llm_agent.py new file mode 100644 index 000000000..3546c1d6f --- /dev/null +++ b/trpc_agent_sdk/agents/_llm_agent.py @@ -0,0 +1,746 @@ +# 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. +"""LLM Agent implementation for TRPC Agent framework. + +This module provides the LlmAgent class which extends BaseAgent to provide +LLM-powered conversational capabilities. It integrates with the model system, +filter framework, and session management to deliver AI agent functionality. +""" + +from __future__ import annotations + +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import List +from typing import Literal +from typing import Optional +from typing import TypeAlias +from typing import Union +from typing_extensions import override + +from pydantic import BaseModel +from pydantic import Field +from pydantic import field_validator + +from trpc_agent_sdk.code_executors import BaseCodeExecutor +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import ModelRegistry +from trpc_agent_sdk.planners import BasePlanner +from trpc_agent_sdk.skills import BaseSkillRepository +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.tools import LongRunningFunctionTool +from trpc_agent_sdk.tools import transfer_to_agent +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig + +from ..exceptions import RunCancelledException +from ._base_agent import BaseAgent +from ._callback import ModelCallback +from ._callback import ToolCallback +from ._constants import TRPC_AGENT_RUNNING_KEY +from .core import BranchFilterMode +from .core import CodeExecutionRequestProcessor +from .core import CodeExecutionResponseProcessor +from .core import LlmProcessor +from .core import TimelineFilterMode +from .core import ToolsProcessor +from .core import create_final_model_response_event +from .core import default_request_processor +from .core import get_structured_model_response + +# Type aliases for instruction providers +InstructionProvider: TypeAlias = Callable[[InvocationContext], Union[str, Awaitable[str]]] + +# Type aliases for tool definitions +ToolUnion: TypeAlias = Union[BaseTool, BaseToolSet] + + +class LlmAgent(BaseAgent): + """LLM-based Agent for TRPC framework. + + This agent provides conversational AI capabilities by integrating with + language models through the model registry system. It supports: + - Model configuration and selection + - Instruction and prompt management + - Tool integration (future enhancement) + - Filter-based processing pipeline + - Session and context management + """ + + model: Union[str, LLMModel, Callable[[dict[str, Any]], Awaitable[LLMModel]]] = "" + """The model to use for the agent. + + Can be either: + - A model name string (resolved via registry) + - A model instance (LLMModel) + - An async factory callback that creates a model per-request + + When not set, the agent will inherit the model from its ancestor. + """ + + instruction: Union[str, InstructionProvider] = "" + """Instructions for the LLM model, guiding the agent's behavior. + + Can be a static string or a callable that takes InvocationContext and returns + instructions. The callable can be async. + """ + + tools: List[ToolUnion] = Field(default_factory=list) + """Tools available to this agent. + + Can be a list of: + - Callable functions (will be wrapped in FunctionTool) + - BaseTool instances (used directly) + - BaseToolSet instances (will be expanded to individual tools) + """ + + parallel_tool_calls: bool = False + """Parallel tool call""" + + generate_content_config: Optional[GenerateContentConfig] = None + """The additional content generation configurations. + + NOTE: not all fields are usable, e.g. tools must be configured via `tools`, + thinking_config must be configured via `planner` in LlmAgent. + + For example: use this config to adjust model temperature, configure safety + settings, etc. + """ + + include_contents: Literal['default', 'none'] = 'default' + """Controls content inclusion in model requests. + + Options: + default: Model receives relevant conversation history + none: Model receives no prior history, operates solely on current + instruction and input + """ + + include_previous_history: bool = True + """Controls whether previous agent history is included in model requests. + + When True (default), previous agent outputs are converted to user context + and included in the conversation history. When False, previous agent outputs + are excluded from history, only keeping user messages and current agent's + own history. + """ + + max_history_messages: int = 0 + """Maximum number of history messages to include in model requests. + + When set to 0 (default), no limit is applied and all filtered messages + are included. When set to a positive value, only the most recent N messages + (after all other filtering) will be included. This is useful for controlling + token usage in long conversations. + + Note: This limit is applied AFTER timeline and branch filtering. + """ + + message_timeline_filter_mode: TimelineFilterMode = TimelineFilterMode.ALL + """Set the filter mode for messages passed to the model (timeline dimension). + + The final messages passed to the model must satisfy both message_timeline_filter_mode + and message_branch_filter_mode conditions. + + Timeline dimension filter conditions: + Default: TimelineFilterMode.ALL + + Optional values: + - TimelineFilterMode.ALL: Includes historical messages as well as messages + generated in the current invocation (runner.run_async() call) + - TimelineFilterMode.INVOCATION: Only includes messages generated in the + current invocation (runner.run_async() call) + """ + + message_branch_filter_mode: BranchFilterMode = BranchFilterMode.ALL + """Set the filter mode for messages passed to the model (branch dimension). + + The final messages passed to the model must satisfy both message_timeline_filter_mode + and message_branch_filter_mode conditions. + + Branch dimension filter conditions: + Default: BranchFilterMode.ALL + + Optional values: + - BranchFilterMode.ALL: Includes messages from all agents. Use this when the + current agent interacts with the model and needs to synchronize all valid + content messages generated by all agents to the model. + - BranchFilterMode.PREFIX: Filters messages by prefix matching Event.branch + with Invocation.branch. Use this when you want to pass messages generated + by the current agent and related upstream/downstream agents to the model. + - BranchFilterMode.EXACT: Filters messages where Event.branch == Invocation.branch. + Use this when the current agent interacts with the model and only needs to + use messages generated by the current agent. + """ + + input_schema: Optional[type[BaseModel]] = None + """The input schema when agent is used as a tool. + + When set, the agent expects structured input matching this Pydantic model. + This is used when the agent is called as a tool by another agent. + """ + + output_schema: Optional[type[BaseModel]] = None + """The output schema for structured responses. + + When set, the agent will provide structured output matching this Pydantic model. + This enables type-safe, structured responses from the agent. + + NOTE: When output_schema is set alongside tools, the agent will use the + SetModelResponseTool to provide structured output while still being able + to use other tools. + """ + + output_key: Optional[str] = None + """Key in session state to store agent output for later use.""" + + planner: Optional[BasePlanner] = None + """Instructs the agent to make a plan and execute it step by step. + + This allows the agent to structure its thinking and reasoning process + before taking actions. Available planners: + - PlanReActPlanner: Enforces structured Plan-Reasoning-Action workflow + - BuiltInPlanner: Uses model's built-in thinking features + + NOTE: To use model's built-in thinking features, set the `thinking_config` + field in `BuiltInPlanner`. + """ + skill_repository: Optional[BaseSkillRepository] = None + """The skill repository to use for the agent. + + When set, the agent will use the skill repository to load skills. + + NOTE: When skill_repository is set, the agent will use the skill repository to load skills. + The skill repository will be used to load skills for the agent. + The skill repository will be used to load skills for the agent. + """ + + before_model_callback: Optional[ModelCallback] = None + """Callback before model is called.""" + + after_model_callback: Optional[ModelCallback] = None + """Callback after model is called.""" + + before_tool_callback: Optional[ToolCallback] = None + """Callback before tool is called.""" + + after_tool_callback: Optional[ToolCallback] = None + """Callback after tool is called.""" + + add_name_to_instruction: bool = True + """Controls whether agent name is added to instruction. + + When True (default), the framework will inject 'You are an agent who's name is [agent_name].' + into the instruction. When False, this injection is disabled, giving full control over the + instruction content. + """ + + disable_react_tool: bool = False + """When True, the agent returns after tool execution instead of continuing the multi-turn loop. + + This is useful when the agent is controlled by an external orchestrator (like TeamAgent) + that wants to handle tool results externally. The orchestrator is responsible for + continuing the conversation with the tool results. + """ + + default_transfer_message: Optional[str] = None + """Controls whether default transfer instructions are added. + + When None (default), the framework will automatically inject transfer instructions via + '_build_transfer_instructions' when agent transfer is enabled. When set to an empty string + or custom message, the default transfer instruction injection is disabled. + + Note: Setting this to an empty string "" completely disables the default transfer message. + Setting it to a custom string will use that string instead of the default message. + """ + + def _get_effective_branch_filter_mode(self) -> BranchFilterMode: + """Get the effective branch filter mode, considering backward compatibility. + + This method provides backward compatibility for include_previous_history: + - If message_branch_filter_mode is explicitly set (not default ALL), use it + - Otherwise, derive from include_previous_history: + - include_previous_history=True -> BranchFilterMode.ALL + - include_previous_history=False -> BranchFilterMode.EXACT + + Returns: + BranchFilterMode: The effective branch filter mode to use + """ + # Check if include_previous_history was explicitly set by checking if it differs from default + # Since we can't directly detect if a field was set, we use a heuristic: + # If message_branch_filter_mode is not ALL, it was explicitly set, so use it + # Otherwise, derive from include_previous_history for backward compatibility + + # If message_branch_filter_mode was explicitly changed from default, use it + if self.message_branch_filter_mode != BranchFilterMode.ALL: + return self.message_branch_filter_mode + + # Otherwise, derive from include_previous_history for backward compatibility + if not self.include_previous_history: + return BranchFilterMode.EXACT + else: + return BranchFilterMode.ALL + + @override + def model_post_init(self, __context: Any) -> None: + """Post init hook for agent.""" + # Skip initialization for factory callbacks - they're resolved per-request + if callable(self.model): + return super().model_post_init(__context) + + # Resolve string models via registry + if not isinstance(self.model, LLMModel): + self.model = ModelRegistry.create_model(self.model) + + return super().model_post_init(__context) + + @property + def _tools_processor(self) -> ToolsProcessor: + """Get the private tools processor instance built from the tools. + + This is a computed field that creates the ToolsProcessor with the agent's raw tools. + The ToolsProcessor will handle BaseToolSet resolution by calling their get_tools() + method during process_llm_request(). + + Returns: + ToolsProcessor: The tools processor instance that handles ToolUnion resolution + + Note: + Tool resolution (including BaseToolSet.get_tools() calls) happens asynchronously + in the ToolsProcessor.process_llm_request() method. + """ + return ToolsProcessor(self.tools) + + def _get_extended_tools_processor(self, ctx: InvocationContext) -> ToolsProcessor: + """Get a tools processor with extended tools including transfer tool and output schema tool if needed. + + This method creates a ToolsProcessor that includes both the agent's original tools + and any additional tools needed for the current context (like transfer tool and set_model_response tool). + + Args: + ctx: The invocation context + + Returns: + ToolsProcessor: Extended tools processor instance + """ + # Start with agent's original tools + extended_tools = self.tools.copy() if self.tools else [] + + # Add transfer tool if agent transfer should be enabled + if self._should_enable_agent_transfer(): + try: + transfer_tool = FunctionTool(transfer_to_agent) + extended_tools.append(transfer_tool) + logger.debug("Added transfer_to_agent tool to extended tools processor for agent: %s", self.name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to add transfer tool to extended processor for agent %s: %s", self.name, ex) + + return ToolsProcessor(extended_tools) + + def _should_enable_agent_transfer(self) -> bool: + """Determine if agent transfer should be enabled for this agent. + + Agent transfer is enabled when the agent has potential transfer targets: + - Has sub-agents + - Has parent agent and transfer to parent is allowed + - Has peer agents and transfer to peers is allowed + + Returns: + bool: True if agent transfer should be enabled + """ + # Check if agent has sub-agents + if self.sub_agents: + return True + + # Check if transfer to parent is possible + if self.parent_agent and not self.disallow_transfer_to_parent and self._is_llm_agent(self.parent_agent): + return True + + # Check if transfer to peers is possible + if (not self.disallow_transfer_to_peers and self.parent_agent and self._is_llm_agent(self.parent_agent) + and len(self.parent_agent.sub_agents) > 1): # Has siblings + return True + + return False + + def _is_llm_agent(self, agent) -> bool: + """Check if an agent is an LlmAgent (supports transfers).""" + return isinstance(agent, LlmAgent) + + async def _resolve_model(self, ctx: InvocationContext) -> LLMModel: + """Resolve model from string, instance, or factory callback. + + This method handles three types of model specifications: + 1. Factory callback: Invoked with custom_data from run_config + 2. String: Resolved via ModelRegistry + 3. LLMModel instance: Used directly + + For factory callbacks, filters are applied to the resolved model. + + Args: + ctx: Invocation context with run_config.custom_data + + Returns: + LLMModel: Resolved model instance ready for use + """ + if callable(self.model): + # Factory pattern - invoke with custom_data + custom_data = ctx.run_config.custom_data if ctx.run_config else {} + model = await self.model(custom_data) + return model + elif isinstance(self.model, str): + # String pattern - resolve via registry + return ModelRegistry.create_model(self.model) + else: + # Already an LLMModel instance + return self.model + + def _create_error_event(self, ctx: InvocationContext, error_code: str, error_message: str) -> Event: + """Create an error event with proper attribution. + + Args: + ctx: The invocation context containing invocation information + error_code: The error code for the event + error_message: The error message for the event + + Returns: + Event: Error event with proper attribution + """ + return Event( + invocation_id=ctx.invocation_id, + author=self.name, + error_code=error_code, + error_message=error_message, + branch=ctx.branch, + ) + + @override + async def _run_async_impl( + self, + ctx: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """Core implementation of LLM agent execution. + + This method implements multi-turn conversation support with clean separation: + 1. Resolve model (may invoke factory callback) + 2. Call the LLM and collect all responses + 3. Collect any tool calls from the LLM response + 4. Execute tools if needed and store results in session + 5. Continue conversation loop until no more tool calls + + Args: + ctx: The invocation context containing session, services, etc. + If ctx.override_messages is set, those messages are used as + conversation context instead of building from session.events. + + Yields: + Event: Agent output events including responses, tool calls, and errors + """ + + # Resolve model (may invoke factory callback) + model_instance = await self._resolve_model(ctx) + llm_processor = LlmProcessor(model_instance) + agent_context = ctx.agent_context + + # Copy override_messages to local mutable list if provided (internal only) + local_messages: Optional[List[Content]] = None + if ctx.override_messages is not None: + local_messages = list(ctx.override_messages) # shallow copy + + def accumulate_content(event: Event) -> None: + """Accumulate non-partial event content to local_messages for multi-turn support.""" + if local_messages is not None and not event.partial and event.content: + local_messages.append(event.content) + + try: + running = agent_context.get_metadata(TRPC_AGENT_RUNNING_KEY, True) + # Multi-turn conversation loop - continue until no more tool calls or code execution + while running: + # CHECKPOINT 1: At start of each conversation turn + await ctx.raise_if_cancelled() + + # Step 1: Build request using the request processor (includes conversation history) + request = LlmRequest(model=model_instance.name, ) + + error_event = await default_request_processor.build_request( + request, + self, + ctx, + override_messages=local_messages, + ) + if error_event: + # Yield the error event directly + yield error_event + return + + # Step 1.5: Process code execution requests if code executor is configured + if self.code_executor: + async for event in CodeExecutionRequestProcessor.run_async(ctx, request): + yield event + + # CHECKPOINT 2: Before LLM call + await ctx.raise_if_cancelled() + + # Step 2: Call LLM and collect all responses + collected_tool_calls = [] + code_was_executed = False + + logger.debug("Starting LLM call for agent: %s", self.name) + + # Use LlmProcessor to get unified events + async for event in llm_processor.call_llm_async(request, ctx, stream=True): + # Handle different event types by checking content and error status + if event.is_error(): + # Error event - yield and stop + yield event + return + elif event.content: + # Skip streaming tool calls (partial=True with streaming_tool_call metadata) + # These events are yielded directly for consumers to handle + if event.is_streaming_tool_call(): + pass + else: + function_calls = event.get_function_calls() + if function_calls: + collected_tool_calls.extend(function_calls) + logger.debug("Collected %s tool calls from LLM", len(function_calls)) + + if event.is_final_response(): + self._save_output_to_state(ctx, event) + + # Process code execution responses if code executor is configured. + # We collect code execution events first (this mutates event.content in place, + # stripping executable_code parts but keeping text/function_call), then yield + # the main event BEFORE the code execution events so the causal order in + # session is preserved: assistant declaration → code execution → result. + pending_code_events: list[Event] = [] + if self.code_executor and event.content: + async for code_event in CodeExecutionResponseProcessor.run_async(ctx, event): + if code_event.content and code_event.content.parts: + for part in code_event.content.parts: + if part.code_execution_result or part.executable_code: + code_was_executed = True + break + pending_code_events.append(code_event) + + # Yield the main LLM response event first (now stripped of executable_code + # but still carrying text and function_call parts). + # Skip empty events (content became None after all parts were consumed). + if event.content is not None: + yield event + accumulate_content(event) + + # Then yield code execution events in order. + for code_event in pending_code_events: + yield code_event + else: + # Yield other events directly + yield event + await ctx.raise_if_cancelled() + + # CHECKPOINT 4: Before tool execution + await ctx.raise_if_cancelled() + + # Step 3: Execute tools if any were collected + if collected_tool_calls: + logger.debug("Executing %s tool calls", len(collected_tool_calls)) + logger.debug("Executing %s tool calls", len(collected_tool_calls)) + + try: + # Use extended tools processor that includes transfer tool if needed + extended_tools_processor = self._get_extended_tools_processor(ctx) + + # Check if any of the tool calls are for long-running tools + long_running_tool_ids = set() + for tool_call in collected_tool_calls: + tool = await extended_tools_processor.find_tool(ctx, tool_call) + if tool and isinstance(tool, LongRunningFunctionTool): + long_running_tool_ids.add(tool_call.id) + + # Execute tools and yield results (Runner will store them automatically) + last_tool_event = None + any_skip_summarization = False + async for tool_event in extended_tools_processor.execute_tools_async(collected_tool_calls, ctx): + last_tool_event = tool_event + if tool_event.actions and tool_event.actions.skip_summarization: + any_skip_summarization = True + + # Check if this event contains responses from long-running tools + if tool_event.content and tool_event.content.parts: + for part in tool_event.content.parts: + if (part.function_response and part.function_response.id in long_running_tool_ids): + # This is a response from a long-running tool + # Find the corresponding function call + corresponding_call = None + for call in collected_tool_calls: + if call.id == part.function_response.id: + corresponding_call = call + break + + if corresponding_call: + # Import LongRunningEvent here to avoid circular imports + from trpc_agent_sdk.events import LongRunningEvent + + # Create and yield LongRunningEvent + long_running_event = LongRunningEvent( + invocation_id=ctx.invocation_id, + author=self.name, + function_call=corresponding_call.model_copy(), + function_response=part.function_response.model_copy(), + branch=ctx.branch, + ) + + # Yield the regular tool event first + yield tool_event + + # Then yield the long-running event + yield long_running_event + + logger.debug("Long-running tool %s completed, yielding LongRunningEvent", + corresponding_call.name) + return # End agent execution after long-running event + + # Yield regular tool events + yield tool_event + accumulate_content(tool_event) + + # CHECKPOINT 5: During tool execution + await ctx.raise_if_cancelled() + + # If set_model_response was executed, create and yield final model response event + if json_response := get_structured_model_response(last_tool_event): + final_event = create_final_model_response_event(ctx, json_response) + self._save_output_to_state(ctx, final_event) + yield final_event + logger.debug("set_model_response executed, ending agent execution") + return + + # Check if any tool requested an agent transfer + if ctx.actions.transfer_to_agent: + # Clear the transfer action state + ctx.actions.transfer_to_agent = None + return # End this agent's execution + + # Check if tool reaction is disabled (external control mode) + # When disable_react_tool is True, external code (like TeamAgent) + # controls the conversation flow. Exit after tool execution to + # let the caller handle tool results and continue the loop. + if self.disable_react_tool: + logger.debug("disable_react_tool set, exiting after tool execution for external control") + return + + # Honor skip_summarization on tool responses: when any tool + # in this batch declares "the tool output IS the final + # answer, do not ask the LLM to summarize it" (e.g. + # `AgentTool(skip_summarization=True)` or + # `StreamingProgressTool(skip_summarization=True)`), + # end this agent without another LLM follow-up call. + # See `EventActions.skip_summarization` docstring. + if any_skip_summarization: + logger.debug("Tool returned skip_summarization=True, exiting without LLM follow-up") + return + + # Continue the multi-turn loop for next LLM call with tool results in history + logger.debug("Tool execution completed, continuing conversation") + continue + + except RunCancelledException: + # raise to runner to handle + raise + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error executing tools for agent %s: %s", self.name, ex, exc_info=True) + + # Create tool execution error event without content + yield self._create_error_event( + ctx, + "tool_execution_failed", + f"Tool execution failed: {str(ex)}", + ) + return + + # CHECKPOINT 6: After tool execution, before loop continuation + await ctx.raise_if_cancelled() + + # Step 4: Check if code was executed and continue loop to let agent summarize results + if code_was_executed: + logger.debug("Code execution completed, continuing conversation for agent to summarize results") + continue + + running = agent_context.get_metadata(TRPC_AGENT_RUNNING_KEY, False) + except RunCancelledException: + # raise to runner to handle + raise + except Exception as ex: # pylint: disable=broad-except + logger.error("Unexpected error in LLM agent %s: %s", self.name, ex, exc_info=True) + + # Create agent execution error event without content + yield self._create_error_event( + ctx, + "agent_execution_failed", + f"Agent execution failed: {str(ex)}", + ) + + def _save_output_to_state(self, ctx: InvocationContext, event: Event) -> None: + """Save agent output to session state if output_key is configured. + + Args: + ctx: The invocation context + content: The content to save + """ + if self.output_key: + # Save output to state using the delta tracking system + result = ''.join([part.text if not part.thought else '' for part in event.content.parts]) + ctx.state[self.output_key] = result + event.actions.state_delta[self.output_key] = result + logger.debug("Saved agent output to state key '%s': %s...", self.output_key, result[:100]) + + @field_validator('generate_content_config', mode='after') + @classmethod + def __validate_generate_content_config( + cls, generate_content_config: Optional[GenerateContentConfig]) -> GenerateContentConfig: + if not generate_content_config: + return GenerateContentConfig() + if generate_content_config.thinking_config: + raise ValueError('Thinking config should be set via LlmAgent.planner.') + if generate_content_config.tools: + raise ValueError('All tools must be set via LlmAgent.tools.') + if generate_content_config.system_instruction: + raise ValueError('System instruction must be set via LlmAgent.instruction.') + if generate_content_config.response_schema: + raise ValueError('Response schema must be set via LlmAgent.output_schema.') + return generate_content_config + + @field_validator('code_executor', mode='after') + @classmethod + def __validate_code_executor(cls, code_executor: Optional[BaseCodeExecutor]) -> Optional[BaseCodeExecutor]: + """Validate code executor configuration.""" + if code_executor and not isinstance(code_executor, BaseCodeExecutor): + raise ValueError('Code executor must be an instance of BaseCodeExecutor.') + return code_executor + + +# Ensure forward references are resolved when this module is imported +# This handles cases where LlmAgent is imported directly without going through __init__.py +def _rebuild_models(): + """Rebuild Pydantic models to resolve forward references.""" + try: + InvocationContext.model_rebuild() + LlmAgent.model_rebuild() + except Exception: # pylint: disable=broad-except + # Ignore rebuild errors during initial import + pass + + +_rebuild_models() diff --git a/trpc_agent_sdk/agents/_parallel_agent.py b/trpc_agent_sdk/agents/_parallel_agent.py new file mode 100644 index 000000000..e474ac5df --- /dev/null +++ b/trpc_agent_sdk/agents/_parallel_agent.py @@ -0,0 +1,212 @@ +# 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. +"""Parallel Agent Implementation. + +This module provides the ParallelAgent class which executes sub-agents concurrently +in isolated contexts. This is beneficial for scenarios requiring multiple perspectives +or approaches on a single task. + +Classes: + ParallelAgent: A shell agent that runs its sub-agents in parallel +""" + +from __future__ import annotations + +import asyncio +import sys +from typing import AsyncGenerator +from typing import List +from typing_extensions import override + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.utils import AsyncClosingContextManager + +from ._base_agent import BaseAgent + + +def _create_branch_ctx_for_sub_agent( + agent: AgentABC, + sub_agent: AgentABC, + invocation_context: InvocationContext, +) -> InvocationContext: + """Create isolated branch for every sub-agent. + + Args: + agent: The parent agent + sub_agent: The sub-agent to create context for + invocation_context: The original invocation context + + Returns: + InvocationContext: A new isolated context for the sub-agent + """ + invocation_context = invocation_context.model_copy() + branch_suffix = f"{agent.name}.{sub_agent.name}" + invocation_context.branch = (f"{invocation_context.branch}.{branch_suffix}" + if invocation_context.branch else branch_suffix) + return invocation_context + + +async def _merge_agent_run_pre_3_11(agent_runs: list[AsyncGenerator[Event, None]]) -> AsyncGenerator[Event, None]: + """Merges the agent run event generator. + This version works in Python 3.9 and 3.10 and uses custom replacement for + asyncio.TaskGroup for tasks cancellation and exception handling. + + This implementation guarantees for each agent, it won't move on until the + generated event is processed by upstream runner. + + Args: + agent_runs: A list of async generators that yield events from each agent. + + Yields: + Event: The next event from the merged generator. + """ + sentinel = object() + queue = asyncio.Queue() + + def propagate_exceptions(tasks): + # Propagate exceptions and errors from tasks. + for task in tasks: + if task.done(): + # Ignore the result (None) of correctly finished tasks and re-raise + # exceptions and errors. + task.result() + + # Agents are processed in parallel. + # Events for each agent are put on queue sequentially. + async def process_an_agent(events_for_one_agent: AsyncGenerator[Event, None]): + try: + async for event in events_for_one_agent: + resume_signal = asyncio.Event() + await queue.put((event, resume_signal)) + # Wait for upstream to consume event before generating new events. + await resume_signal.wait() + finally: + # Mark agent as finished. + await queue.put((sentinel, None)) + + tasks: List[asyncio.Task] = [] + try: + for events_for_one_agent in agent_runs: + tasks.append(asyncio.create_task(process_an_agent(events_for_one_agent))) + + sentinel_count = 0 + # Run until all agents finished processing. + while sentinel_count < len(agent_runs): + propagate_exceptions(tasks) + entry: tuple[Event, asyncio.Event] = await queue.get() + # Agent finished processing. + if entry[0] is sentinel: + sentinel_count += 1 + else: + yield entry[0] + # Signal to agent that event has been processed by runner and it can + # continue now. + entry[1].set() + finally: + for task in tasks: + task.cancel() + + +async def _merge_agent_run(agent_runs: list[AsyncGenerator[Event, None]]) -> AsyncGenerator[Event, None]: + """Merges the agent run event generator. + + This implementation guarantees for each agent, it won't move on until the + generated event is processed by upstream runner. + + Args: + agent_runs: A list of async generators that yield events from each agent. + + Yields: + Event: The next event from the merged generator. + """ + sentinel = object() + queue = asyncio.Queue() + + # Agents are processed in parallel. + # Events for each agent are put on queue sequentially. + async def process_an_agent(events_for_one_agent: AsyncGenerator[Event, None]): + try: + async for event in events_for_one_agent: + resume_signal = asyncio.Event() + await queue.put((event, resume_signal)) + # Wait for upstream to consume event before generating new events. + await resume_signal.wait() + finally: + # Mark agent as finished. + await queue.put((sentinel, None)) + + async with asyncio.TaskGroup() as tg: + for events_for_one_agent in agent_runs: + tg.create_task(process_an_agent(events_for_one_agent)) + + sentinel_count = 0 + # Run until all agents finished processing. + while sentinel_count < len(agent_runs): + entry: tuple[Event, asyncio.Event] = await queue.get() + # Agent finished processing. + if entry[0] is sentinel: + sentinel_count += 1 + else: + yield entry[0] + # Signal to agent that it should generate next event. + entry[1].set() + + +class ParallelAgent(BaseAgent): + """A shell agent that runs its sub-agents in parallel in isolated manner. + + This approach is beneficial for scenarios requiring multiple perspectives or + attempts on a single task, such as: + + - Running different algorithms simultaneously + - Generating multiple responses for review by a subsequent evaluation agent + - Parallel processing of independent subtasks + + Example: + ```python + parallel_agent = ParallelAgent( + name="multi_approach_solver", + description="Solve problems using multiple approaches in parallel", + sub_agents=[ + algorithm_a_agent, + algorithm_b_agent, + heuristic_agent + ] + ) + ``` + """ + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Execute sub-agents in parallel with isolated contexts. + + Args: + ctx: The invocation context for this agent + + Yields: + Event: Events from all sub-agents merged as they become available + """ + agent_runs: List[AsyncGenerator[Event, None]] = [] + for sub_agent in self.sub_agents: + if not sub_agent: + continue + sub_agent_run = sub_agent.run_async(_create_branch_ctx_for_sub_agent(self, sub_agent, ctx)) + agent_runs.append(sub_agent_run) # type: ignore + try: + # TODO remove if once Python <3.11 is no longer supported. + if sys.version_info >= (3, 11): + async with AsyncClosingContextManager(_merge_agent_run(agent_runs)) as agen: + async for event in agen: + yield event + else: + async with AsyncClosingContextManager(_merge_agent_run_pre_3_11(agent_runs)) as agen: + async for event in agen: + yield event + finally: + for sub_agent_run in agent_runs: + await sub_agent_run.aclose() diff --git a/trpc_agent_sdk/agents/_transfer_agent.py b/trpc_agent_sdk/agents/_transfer_agent.py new file mode 100644 index 000000000..61dcf2ef6 --- /dev/null +++ b/trpc_agent_sdk/agents/_transfer_agent.py @@ -0,0 +1,162 @@ +# 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. +"""Transfer Agent Implementation. + +Provides TransferAgent class for custom agents that lack transfer capabilities. +""" + +from __future__ import annotations + +from typing import Any +from typing import AsyncGenerator +from typing import List +from typing import Optional +from typing import Union +from typing_extensions import override + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.models import LLMModel + +from ._base_agent import BaseAgent +from ._llm_agent import LlmAgent + +TRPC_TRANSFER_AGENT_RESULT_KEY = "trpc_transfer_agent_result" +TRPC_TRANSFER_INSTRUCTION_KEY = "trpc_transfer_instruction" +TRPC_TRANSFER_AGENT_LIST_KEY = "trpc_transfer_agent_list" + +# Default transfer instruction when not provided +_DEFAULT_TRANSFER_INSTRUCTION = """1. If the content indicates that the task requires specialized expertise that matches + a sub_agent's description, transfer to that sub_agent +2. If the content contains errors, incomplete information, or requests for additional help, + consider transferring to an appropriate sub_agent +3. If the content is complete and satisfactory, do not transfer +4. Choose the most appropriate sub_agent based on their description and the content's needs + +Return should_transfer=true only if there is a clear match between the content's needs +and a sub_agent's capabilities.""" + +_DECISION_INSTRUCTION = """Based on the following target agent result and the rules below, +decide whether to transfer to a sub-agent. + +- If yes: call the transfer_to_agent tool with the target agent name only. Do not output other text. +- If no: do not call any tool and do not output any content (no "should_transfer=...", no JSON, no explanation). + +Target agent result: +{trpc_transfer_agent_result} + +Rules: +{trpc_transfer_instruction} + +Available sub-agents: +{trpc_transfer_agent_list}""" + + +class TransferAgent(BaseAgent): + """Transfer proxy for custom agents that lack transfer capabilities. + + Always calls target agent first, then optionally transfers to sub-agents based on + transfer instructions. Agent name is auto-generated as "transfer_{target_agent.name}". + + Behavior: + - No sub_agents: Directly transfer to target agent + - Has sub_agents: Call target agent, analyze result, decide transfer + """ + + _target_agent: BaseAgent + """The target agent that TransferAgent will always call first.""" + + _model: Union[str, LLMModel, Any] + """The model to use for transfer decision.""" + + _transfer_instruction: str + """Transfer instructions for deciding whether to transfer to sub-agents.""" + + _sub_agents: List[AgentABC] + """Sub-agents that can be transferred to after target agent execution.""" + + @property + def target_agent(self) -> BaseAgent: + """Target agent.""" + return self._target_agent + + @override + def get_subagents(self) -> List[AgentABC]: + return [self._target_agent] + self._sub_agents + + @override + def find_sub_agent(self, name: str) -> Optional[AgentABC]: + """Use get_subagents() so find_agent can resolve transfer targets.""" + for sub_agent in self.get_subagents(): + if result := sub_agent.find_agent(name): + return result + return None + + def __init__( + self, + agent: BaseAgent, + model: Union[str, LLMModel, Any], + sub_agents: Optional[List[AgentABC]] = None, + transfer_instruction: str = "", + ) -> None: + """Initialize TransferAgent. + + Args: + agent: Target agent (required). + model: Model for transfer decision (required). + sub_agents: Optional sub-agents to transfer to. If empty, directly transfer to target agent. + transfer_instruction: Custom transfer rules. Uses default if empty. + """ + final_sub_agents = [a for a in (sub_agents or []) if a != agent] + if not transfer_instruction.strip(): + transfer_instruction = _DEFAULT_TRANSFER_INSTRUCTION + + super().__init__( + name=f"{agent.name}_transfer_proxy", + description=f"Transfer proxy for {agent.name}", + ) + + self._target_agent = agent + self._model = model + self._transfer_instruction = transfer_instruction + self._sub_agents = final_sub_agents + self._route_agent = LlmAgent( + name=f"{agent.name}_transfer", + description="Internal agent for transfer decision", + model=model, + instruction=_DECISION_INSTRUCTION, + sub_agents=final_sub_agents, + ) + self._route_agent.parent_agent = self.parent_agent + + @override + async def _run_async_impl( + self, + ctx: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """Execute TransferAgent. + + If no sub_agents: directly transfer to target agent. + If has sub_agents: call target agent, analyze result, decide transfer. + """ + result_text = "" + async for event in self._target_agent.run_async(ctx): + if event.actions and event.actions.state_delta: + ctx.state.update(event.actions.state_delta) + if not event.partial and event.content and event.content.parts: + for part in event.content.parts: + if part.text: + result_text += part.text + "\n" + yield event + + ctx.state[TRPC_TRANSFER_AGENT_RESULT_KEY] = result_text.strip() or "(no content)" + ctx.state[TRPC_TRANSFER_INSTRUCTION_KEY] = (self._transfer_instruction.strip() or _DEFAULT_TRANSFER_INSTRUCTION) + ctx.state[TRPC_TRANSFER_AGENT_LIST_KEY] = ("\n".join(f"- {a.name}: {a.description}" + for a in self._sub_agents) if self._sub_agents else "None") + + async for event in self._route_agent.run_async(ctx): + yield event diff --git a/trpc_agent_sdk/agents/core/README.md b/trpc_agent_sdk/agents/core/README.md new file mode 100644 index 000000000..98a9b0b4a --- /dev/null +++ b/trpc_agent_sdk/agents/core/README.md @@ -0,0 +1,406 @@ +# trpc_agent/agents/core 说明 + +本文档面向读者解释 [`trpc_agent/agents/core`](./) 中与 Skills 相关的请求处理逻辑,重点覆盖三个处理器: + +- `SkillsRequestProcessor`([`_skill_processor.py`](./_skill_processor.py)) +- `WorkspaceExecRequestProcessor`([`_workspace_exec_processor.py`](./_workspace_exec_processor.py)) +- `SkillsToolResultRequestProcessor`([`_skills_tool_result_processor.py`](./_skills_tool_result_processor.py)) + +文档目标是回答三个问题: + +1. 每个处理器解决什么问题 +2. 处理器在请求流水线中的位置与协作关系 +3. 如何在运行时判断“功能已生效” + +## 1. 请求流水线中的职责划分 + +在 `RequestProcessor` 的技能相关路径中(简化): + +1. 组装基础 instruction +2. 注入 tools +3. `SkillsRequestProcessor`:注入 skills 总览与(可选)已加载内容 +4. `WorkspaceExecRequestProcessor`:注入 `workspace_exec` guidance +5. 注入会话历史 +6. `SkillsToolResultRequestProcessor`:在 post-history 阶段做 tool result 物化 +7. 其他能力(planning/output schema 等) + +可理解为: + +- `SkillsRequestProcessor` 负责“技能上下文主编排” +- `WorkspaceExecRequestProcessor` 负责“执行器工具选择引导” +- `SkillsToolResultRequestProcessor` 负责“tool_result_mode 下的内容物化补强” + +## 2. SkillsRequestProcessor(核心入口) + +主入口: + +- `SkillsRequestProcessor.process_llm_request(ctx, request)` + +### 2.1 解决的问题 + +模型在多技能场景需要两类信息: + +- 可用 skill 总览(有哪些技能、各自做什么) +- 已加载 skill 的正文/文档/工具选择(当前上下文真正可用什么) + +`SkillsRequestProcessor` 提供统一策略来管理这些信息,并在 `turn/once/session` 三种加载模式下保持行为一致。 + +### 2.2 核心行为 + +一次调用中,典型流程如下: + +1. 获取 skill repo(支持 `repo_resolver(ctx)` 动态仓库) +2. 执行旧状态迁移(兼容历史 key)与 turn 模式清理 +3. 注入 skill 概览(始终执行) +4. 读取 loaded skills 并按 `max_loaded_skills` 裁剪 +5. 按 `load_mode` 解析状态键并读取(见下文 **temp-only**) +6. 根据 `tool_result_mode` 分流: + - `False`:直接向 system instruction 注入 `[Loaded]`、`Docs loaded`、`[Doc]` + - `True`:跳过注入,交给 `SkillsToolResultRequestProcessor` 处理 +7. `load_mode=once` 时清理 loaded/docs/tools/order 状态(offload) + +### 2.3 状态语义 + +**temp-only 状态模型**:技能 loaded/docs/tools 状态不再维护 `temp:skill:*` 与 `user:skill:*` 两套键,也不再做 temp → user 的 promote(`_maybe_promote_skill_state_for_session` 为 no-op)。统一由 `loaded_state_key()` 等函数([`_state_keys.py`](../../skills/_state_keys.py))按 `load_mode` 决定键名: + +| `load_mode` | 键名 | 生命周期 | +|-------------|------|----------| +| `turn` / `once` | 带 `temp:` 前缀,如 `temp:skill:loaded_by_agent:/` | `turn` 每轮 invocation 清空;`once` 注入后 offload | +| `session` | 去掉 `temp:` 前缀,如 `skill:loaded_by_agent:/` | 整个 session 内保留 | + +读取一律走 `session_state + state_delta` 合并视图(`_snapshot_state`),`turn` 与 `session` 共用同一套解析函数,仅键前缀与清理策略不同。 + +各模式清理策略: + +- `turn` + - 每次 invocation 开始清理一次技能状态 + - 对应:`_maybe_clear_skill_state_for_turn` +- `once` + - 本轮用完后清理,避免持续占用上下文 + - 对应:`_maybe_offload_loaded_skills` +- `session` + - 不做 turn 级清空,也不做 once 级 offload;状态写入无 `temp:` 前缀的键 + +### 2.4 关键参数 + +- `load_mode`: `turn` / `once` / `session` +- `tool_result_mode`: 是否改为 tool result materialization 路径 +- `tool_profile` / `allowed_skill_tools` / `tool_flags`: 限制可用技能工具能力面 +- `exec_tools_disabled`: 关闭交互执行 guidance +- `repo_resolver`: invocation 级仓库解析 +- `max_loaded_skills`: loaded 上限(超限按顺序淘汰) + +参数入口: + +- `set_skill_processor_parameters(agent_context, parameters)` + +## 3. WorkspaceExecRequestProcessor(workspace_exec guidance) + +对应实现: + +- [`trpc_agent/agents/core/_workspace_exec_processor.py`](./_workspace_exec_processor.py) + +### 3.1 解决的问题 + +在多工具场景下,模型容易混淆: + +- 什么时候使用 `workspace_exec`(通用 shell) +- 什么时候使用 `skill_run`(技能内部执行) +- `workspace_exec` 的路径边界、会话工具、artifact 保存边界 + +处理器通过注入统一 guidance,降低误用和误判。 + +### 3.2 主要行为 + +`process_llm_request(ctx, request)` 典型步骤: + +1. 判断是否启用 guidance + - 默认按 request tools 是否包含 `workspace_exec` + - 支持 `enabled_resolver` 动态开关 +2. 生成 guidance 主体 + - 通用 `workspace_exec` 使用建议 + - `work/out/runs` 路径建议 + - “先用小命令验证环境限制”的原则 +3. 按能力追加段落 + - 有 `workspace_save_artifact`:追加 artifact 保存边界说明 + - 有 skills repo:提示 `skills/` 目录并非自动 stage + - 有会话工具:追加 `workspace_write_stdin` / `workspace_kill_session` 生命周期提示 +4. 去重注入 + - 若已存在 `Executor workspace guidance:` header,则不重复追加 + +### 3.3 行为示例 + +当工具列表包含: + +- `workspace_exec` +- `workspace_write_stdin` +- `workspace_kill_session` +- `workspace_save_artifact` + +且 agent 绑定 skill repository 时,system instruction 会引导模型: + +- 通用 shell 优先走 `workspace_exec` +- 路径优先 `work/`、`out/`、`runs/` +- 限制不先假设,先验证 +- 仅在需要稳定引用时再调用 `workspace_save_artifact` + +### 3.4 常见误区 + +- 误区:`workspace_exec` 会自动准备 `skills/` 内容 + 实际:是否存在 `skills/...` 取决于是否有其他工具先 stage + +- 误区:遇到限制直接下结论“环境不支持” + 实际:应先做有界验证 + +- 误区:所有输出都必须保存 artifact + 实际:应按稳定引用需求再保存 + +### 3.5 如何验证生效 + +优先看“发给模型前的请求”而非仅看终端事件: + +1. `request.config.system_instruction` 是否包含 `Executor workspace guidance:` +2. 是否只注入一次(无重复 header) +3. 工具选择行为是否符合预期(通用 shell 走 `workspace_exec`) + +## 4. SkillsToolResultRequestProcessor(tool result 物化) + +对应实现: + +- [`trpc_agent/agents/core/_skills_tool_result_processor.py`](./_skills_tool_result_processor.py) + +### 4.1 解决的问题 + +仅靠 `skill_load` 的短回包(例如 `"skill 'python-math' loaded"`),模型往往拿不到可执行细节。 +这个处理器负责把“已加载 skill 的实质内容”物化到模型当前请求上下文。 + +### 4.2 主要行为 + +处理器会: + +1. 从 `session_state + state_delta` 读取已加载 skill +2. 在 `LlmRequest.contents` 中定位最近的 `skill_load` / `skill_select_docs` response +3. 条件满足时改写 response,注入: + - `[Loaded] ` + - `Docs loaded: ...` + - `[Doc] ...` +4. 若本轮没有可改写 response,fallback 到 system instruction 追加 `Loaded skill context:` +5. `load_mode=once` 时按策略清理 loaded/docs 状态 + +### 4.3 与 SkillsRequestProcessor 的分工 + +- `tool_result_mode=False` + - 由 `SkillsRequestProcessor` 直接注入 loaded 内容 +- `tool_result_mode=True` + - `SkillsRequestProcessor` 不注入 loaded 内容 + - `SkillsToolResultRequestProcessor` 在 post-history 做物化 + +### 4.4 最小示例 + +进入处理器前: + +- function call: `skill_load(demo-skill)` +- function response: `{"result":"skill 'demo-skill' loaded"}` + +处理器后(示意): + +```text +{ + "result": "[Loaded] demo-skill\n\n\n\nDocs loaded: docs/guide.md\n\n[Doc] docs/guide.md\n\n" +} +``` + +即使没有对应 tool response 可改写,也会通过 system instruction fallback 注入已加载上下文。 + +### 4.5 什么时候会被误判为“没生效” + +最常见误区:只看终端工具即时回包。 +该处理器真实生效点是“发给模型前的请求内容”,与外层事件流并不总是 1:1。 + +建议观察: + +- `request.config.system_instruction` +- `request.contents` 里的 function response 是否已被改写 + +### 4.6 参数入口 + +- `load_mode` +- `skip_fallback_on_session_summary` +- `repo_resolver` + +通过: + +- `set_skill_tool_result_processor_parameters(agent_context, {...})` + +注入请求构建链路。 + +## 5. 测试语义映射(与 examples 对齐) + +可配合 [`examples/skills/run_agent.py`](../../../examples/skills/run_agent.py) 与 +[`examples/skills/README.md`](../../../examples/skills/README.md) 观察实际行为。 + +- `workspace_exec_guidance` 类测试 + - 关注工具选择行为是否被 guidance 纠偏 + - 核心断言是 `workspace_exec` 与 `skill_run` 调用分布 + +- `skills_tool_result_mode` 类测试 + - 关注 materialization 信号是否出现(`[Loaded]` / `Docs loaded` / `[Doc]`) + - 允许“请求层可见但终端不完全回显”的情况 + +## 6. 给读者的排障建议 + +1. 先确认模式参数:`load_mode`、`tool_result_mode` +2. 再确认状态读写:`state_delta` 与 `session_state` 是否符合预期 +3. 最后看请求最终形态: + - 是否注入了 guidance + - 是否注入了 loaded context + - 是否发生了 offload/clear + +## 附录 + +### A. `turn` 与 `once` 的区别 + +二者都属于**临时状态**(键名带 `temp:` 前缀),差异在于**何时清空 loaded 状态**,以及**同一 invocation 内是否会反复注入 skill 正文**。 + +> **术语(避免与后文「轮」混淆)** +> +> | 术语 | 含义 | +> |------|------| +> | **invocation** | 用户发**一条消息**后,Runner 从开始处理到结束的整段流程(其间可有多次 LLM 调用、多次 tool 调用) | +> | **LLM 调用** | 每次调用模型前执行一次 `process_llm_request`(下文记为 LLM #1、#2、#3 …) | +> | **用户消息 #N** | 第 N 条用户输入,通常对应第 N 个 invocation | +> +> `turn` 的清空发生在**新 invocation 开始时**(仅一次),不是每次 LLM 调用前。因此: +> - **同一 invocation 内**(同一条用户消息、多次 LLM):`skill_load` 写入的 state **会保留**,故 LLM #2、#3 都能读到并重复注入; +> - **跨 invocation**(用户消息 #1 → 用户消息 #2):消息 #2 开始时 **会清空** 消息 #1 留下的 state。 +> +> 下文「同一轮内」= 同一 invocation;「下一轮」= 下一条用户消息(新 invocation)。二者不矛盾。 + +#### 对比一览 + +| | `turn` | `once` | +|---|--------|--------| +| **清空时机** | 每次新 invocation(用户新发一条消息)**开始时**清空 | 每次将 skill 内容注入请求**之后**清空(offload) | +| **同一 invocation 内多步 Agent 循环** | `skill_load` 后的状态会保留,**每一步 LLM 调用都会重新注入** skill 正文 | 注入 loaded 后 offload;**后续 LLM 不再从 state 注入**(除非再次 `skill_load`) | +| **跨用户消息** | 下一条用户消息(新 invocation)开始时清空 | 不在 invocation 开始时统一清空;靠「注入后 offload」释放 state | +| **典型用途** | 一轮内多步工具调用都需要完整 skill 上下文 | 控制 token:skill 正文只进一次 prompt,之后靠历史 / tool result | +| **对应函数** | `_maybe_clear_skill_state_for_turn` | `_maybe_offload_loaded_skills` | + +#### 清空边界(常见误解) + +下文「清」均指清除 **skill loaded/docs/tools 的 session state**,不是清除聊天历史。 + +**`turn`:跨 invocation(用户消息)清,同一 invocation 内不清** + +- **一次请求** = 用户发一条消息 → 一次 `run_async(..., new_message=...)` → 一个 invocation。 +- **一次请求里的多轮** = 同一条消息内 Agent 循环(LLM #1 → 工具 → LLM #2 → …,多次 `process_llm_request`)。 +- `turn` 只在**新 invocation 的第一次** `process_llm_request` 时清空(`processor:skills:turn_init` 保证同一条消息内后续 LLM 不再清)。 + +```text +用户消息 #1(invocation A) + 开始 → [清] 只清「更早 invocation」留下的 state + LLM #1 → skill_load → LLM #2 → skill_run → LLM #3 ← 中间不再清 + +用户消息 #2(invocation B) + 开始 → [清] 清掉消息 #1 结束时残留的 loaded 标记 +``` + +归纳:**同一条用户消息内的多次 LLM 不清;下一条用户消息(新 invocation)开始时清。** + +**`once`:注入 loaded 之后清,不是每个 LLM 轮次都清** + +- offload 仅在 `_maybe_offload_loaded_skills` 中触发,且要求本次 `process_llm_request` 读到的 `loaded` **非空**(见 `_skill_processor.py`)。 +- 尚未 `skill_load` 的 LLM 调用(`loaded` 为空)**不会**触发 offload。 + +```text +用户消息 #1(一个 invocation) + LLM #1:尚无 loaded → 不 offload + skill_load → 写入 state + LLM #2:注入 [Loaded] ... → [offload 清 state] + skill_run + LLM #3:state 已空 → 不再从 state 注入(除非再次 skill_load) +``` + +归纳:**不是「一次请求里每一轮 LLM 都清」,而是「有 loaded 且本次请求完成注入后清」。** + +**一句话对比** + +| 模式 | 清空边界 | 同一条用户消息内多次 LLM | +|------|----------|--------------------------| +| `turn` | **新用户消息**开始时清 | state **保留**,每步 LLM 都可能重复注入 skill 正文 | +| `once` | **每次注入 loaded 内容之后**清 | 通常只在 `skill_load` 后的那一次 LLM 从 state 注入正文 | +| `session` | 不做 turn 级开头清、不做 once 级注入后清 | 键名去掉 `temp:`,可跨多条用户消息保留 | + +#### 举例:一轮内先 `skill_load` 再 `skill_run` + +用户问:「用 data-analysis 分析 CSV」。Agent 在同一 invocation 内通常会经历多次 LLM 调用: + +1. 第 1 次 LLM → 决定调用 `skill_load` +2. 执行 `skill_load` → 写入 state +3. 第 2 次 LLM → 构建请求、注入 skill 内容 +4. 决定调用 `skill_run` +5. 第 3 次 LLM → 继续推理 + +**`turn` 模式** + +```text +用户消息 #1 开始 + → [清空] 上一轮 skill 状态 + → LLM #1:只有 skill 概览,尚无 loaded 正文 + → skill_load("data-analysis") // 写入 state + → LLM #2:system 里注入 [Loaded] ... // 同一 invocation 内 state 仍在 + → skill_run(...) + → LLM #3:再次注入 [Loaded] ... // 同一 invocation 内重复注入(尚未跨用户消息) +``` + +特点:同一轮内每一步 LLM 请求都能看到完整 skill 正文,适合多步推理时上下文需持续「在线」;代价是 token 重复消耗。 + +**`once` 模式** + +```text +用户消息 #1 + → LLM #1:概览 + → skill_load("data-analysis") + → LLM #2:注入 [Loaded] ... → [offload 清空 state] + → skill_run(...) + → LLM #3:state 已空,不再从 state 注入 skill 正文 + (若开启 tool_result_mode,正文可能在 history 的 function_response 里) +``` + +特点:skill 正文只在 `skill_load` 后的**那一次**请求里注入,随后从 session state 删除,避免后续每步 LLM 重复塞入 SKILL.md;适合省 token,后续步骤依赖对话历史或 `SkillsToolResultRequestProcessor` 物化结果。 + +#### 举例:连续两条用户消息 + +**`turn`** + +```text +用户消息 #1:skill_load + 完成任务 + → 结束时 state 里可能仍有 loaded 标记 + +用户消息 #2 开始(新 invocation) + → [清空] 消息 #1 的 skill 状态 + → 若本条消息要再用 skill,需重新 skill_load +``` + +**跨 invocation**(用户消息 #1 → 用户消息 #2)时,消息 #1 的 skill state **不会**带到消息 #2;这与消息 #1 **内部** LLM #2、#3 之间 state 仍保留并不冲突。 + +**`once`** + +```text +用户消息 #1 + → 每次注入后 offload,通常不会长期保留 loaded state + +用户消息 #2 + → 不会在 invocation 开始时主动统一 wipe + → 一般仍需重新 skill_load;重点在于「单次注入后立即释放 state」 +``` + +#### 与 `session` 对比(选型参考) + +| 场景 | 建议 | +|------|------| +| 一轮内多次 LLM + 工具,每步都要完整 skill 文档 | `turn`(默认) | +| skill 正文很大,只想注入一次,后续靠历史 | `once` | +| 同一会话多轮对话都要复用已加载 skill | `session` | + +`examples/skills` 未显式配置 `load_mode` 时一般为 **`turn`**。若 skill 文档较大且一轮内会多次调 LLM,可尝试 `once` 配合 `tool_result_mode=True`,将正文物化进 tool result,而不是每步重复写入 system instruction。 diff --git a/trpc_agent_sdk/agents/core/__init__.py b/trpc_agent_sdk/agents/core/__init__.py new file mode 100644 index 000000000..c8b5922c5 --- /dev/null +++ b/trpc_agent_sdk/agents/core/__init__.py @@ -0,0 +1,56 @@ +# 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. +"""Core agent components module. + +This module exports the core components for agent processing and execution, +including LLM processors, tool processors, and request processors. +""" + +from ._agent_transfer_processor import AgentTransferProcessor +from ._code_execution_processor import CodeExecutionRequestProcessor +from ._code_execution_processor import CodeExecutionResponseProcessor +from ._code_execution_processor import DataFileUtil +from ._history_processor import BranchFilterMode +from ._history_processor import HistoryProcessor +from ._history_processor import TimelineFilterMode +from ._llm_processor import LlmProcessor +from ._output_schema_processor import OutputSchemaRequestProcessor +from ._output_schema_processor import create_final_model_response_event +from ._output_schema_processor import get_structured_model_response +from ._request_processor import RequestProcessor +from ._request_processor import default_request_processor +from ._skill_processor import SkillsRequestProcessor +from ._skill_processor import get_skill_processor_parameters +from ._skill_processor import set_skill_processor_parameters +from ._skills_tool_result_processor import get_skill_tool_result_processor_parameters +from ._skills_tool_result_processor import set_skill_tool_result_processor_parameters +from ._tools_processor import ToolsProcessor +from ._workspace_exec_processor import get_workspace_exec_processor_parameters +from ._workspace_exec_processor import set_workspace_exec_processor_parameters + +__all__ = [ + "AgentTransferProcessor", + "CodeExecutionRequestProcessor", + "CodeExecutionResponseProcessor", + "DataFileUtil", + "BranchFilterMode", + "HistoryProcessor", + "TimelineFilterMode", + "LlmProcessor", + "OutputSchemaRequestProcessor", + "create_final_model_response_event", + "get_structured_model_response", + "RequestProcessor", + "default_request_processor", + "SkillsRequestProcessor", + "get_skill_processor_parameters", + "set_skill_processor_parameters", + "get_skill_tool_result_processor_parameters", + "set_skill_tool_result_processor_parameters", + "ToolsProcessor", + "get_workspace_exec_processor_parameters", + "set_workspace_exec_processor_parameters", +] diff --git a/trpc_agent_sdk/agents/core/_agent_transfer_processor.py b/trpc_agent_sdk/agents/core/_agent_transfer_processor.py new file mode 100644 index 000000000..1286b23aa --- /dev/null +++ b/trpc_agent_sdk/agents/core/_agent_transfer_processor.py @@ -0,0 +1,211 @@ +# 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. +"""Agent Transfer Processor implementation for TRPC Agent framework. + +This module provides the AgentTransferProcessor class which handles agent transfer +functionality. It identifies available transfer targets and adds appropriate +instructions and tools to enable LLM-controlled agent transfers. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing import TYPE_CHECKING + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest + +from .._base_agent import BaseAgent + +if TYPE_CHECKING: + from .._llm_agent import LlmAgent + + +class AgentTransferProcessor: + """Processor for handling agent transfer functionality. + + This class manages the addition of transfer capabilities to LLM requests, + including identifying transfer targets and adding appropriate instructions. + """ + + async def process_agent_transfer(self, request: LlmRequest, agent: "LlmAgent", + ctx: InvocationContext) -> Optional[Event]: + """Add agent transfer capabilities to the LLM request. + + This method: + 1. Identifies valid transfer targets for the agent + 2. Adds instructions about available agents + 3. Ensures the transfer_to_agent tool is available in the agent's tools + + Args: + request: The model request to add transfer capabilities to + agent: The LlmAgent to process transfer for + ctx: The invocation context + + Returns: + Event: Error event if processing fails, None if successful + """ + try: + # Get transfer targets for this agent + transfer_targets = self._get_transfer_targets(agent) + + if not transfer_targets: + logger.debug("No transfer targets found for agent: %s", agent.name) + return None + + # Add transfer instructions to the request + transfer_instructions = self._build_transfer_instructions(agent, transfer_targets) + if transfer_instructions: + # Add to system prompt - append to existing instructions + current_system = request.config.system_instruction or "" + if current_system: + combined_instructions = f"{current_system}\n\n{transfer_instructions}" + else: + combined_instructions = transfer_instructions + request.config.system_instruction = combined_instructions + + logger.debug("Added transfer instructions for %s targets to agent: %s", len(transfer_targets), + agent.name) + + # The transfer_to_agent tool should already be included in the agent's tools + # when agent transfer is enabled. The tools processor will handle it automatically. + logger.debug("Agent transfer processing completed for agent: %s", agent.name) + + return None # Success + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing agent transfer for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "agent_transfer_error", f"Failed to process agent transfer: {str(ex)}") + + def _create_error_event(self, ctx: InvocationContext, error_code: str, error_message: str) -> Event: + """Create an error event with the agent name from context. + + Args: + ctx: The invocation context containing agent information + error_code: The error code for the event + error_message: The error message for the event + + Returns: + Event: Error event with proper attribution + """ + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + error_code=error_code, + error_message=error_message, + ) + + def _get_transfer_targets(self, agent: "LlmAgent") -> List[BaseAgent]: + """Get valid transfer targets for the given agent. + + Transfer targets include: + - All sub-agents of the current agent + - Parent agent (if transfer to parent is allowed) + - Peer agents (if transfer to peers is allowed) + + Args: + agent: The agent to get transfer targets for + + Returns: + List of valid transfer target agents + """ + targets = [] + + # Add sub-agents + targets.extend(agent.sub_agents) + + # Add parent agent if allowed and exists + if agent.parent_agent and not agent.disallow_transfer_to_parent and self._is_llm_agent(agent.parent_agent): + targets.append(agent.parent_agent) + + # Add peer agents if allowed + if not agent.disallow_transfer_to_peers and agent.parent_agent and self._is_llm_agent(agent.parent_agent): + # Get all siblings (peer agents) + peer_agents = [peer_agent for peer_agent in agent.parent_agent.sub_agents if peer_agent.name != agent.name] + targets.extend(peer_agents) + + logger.debug("Found %s transfer targets for agent %s: %s", len(targets), agent.name, [t.name for t in targets]) + + return targets + + def _is_llm_agent(self, agent: BaseAgent) -> bool: + """Check whether *agent* is LlmAgent-compatible without importing LlmAgent. + + Uses runtime class MRO names so it also works for subclasses of LlmAgent. + This avoids circular imports in transfer processing code paths. + """ + cls = agent.get_agent_class() + return any(base.__name__ == "LlmAgent" for base in cls.__mro__) + + def _build_transfer_instructions(self, agent: "LlmAgent", target_agents: List[BaseAgent]) -> str: + """Build transfer instructions for the LLM. + + Args: + agent: The current agent + target_agents: List of available transfer targets + + Returns: + Instruction text for the LLM about agent transfers + """ + if not target_agents: + return "" + + # Check if agent has custom default_transfer_message + # If default_transfer_message is not None, use it (even if it's an empty string) + if hasattr(agent, 'default_transfer_message') and agent.default_transfer_message is not None: + return agent.default_transfer_message + + # Build information about each target agent + target_info_lines = [] + for target_agent in target_agents: + target_info = self._build_target_agent_info(target_agent) + target_info_lines.append(target_info) + + # Build the main transfer instructions + instructions = f""" +You have a list of other agents to transfer to: + +{chr(10).join(target_info_lines)} + +If you are the best to answer the question according to your description, you +can answer it. + +If another agent is better for answering the question according to its +description, call `transfer_to_agent` function to transfer the +question to that agent. When transferring, do not generate any text other than +the function call. +""" + + # Add parent agent guidance if applicable + if agent.parent_agent and not agent.disallow_transfer_to_parent and self._is_llm_agent(agent.parent_agent): + instructions += f""" +Your parent agent is {agent.parent_agent.name}. If neither the other agents nor +you are best for answering the question according to the descriptions, transfer +to your parent agent. +""" + + return instructions.strip() + + def _build_target_agent_info(self, target_agent: BaseAgent) -> str: + """Build information string for a target agent. + + Args: + target_agent: The agent to build info for + + Returns: + Formatted agent information string + """ + return f""" +Agent name: {target_agent.name} +Agent description: {target_agent.description} +""" + + +# Create a default instance for convenience +default_agent_transfer_processor = AgentTransferProcessor() diff --git a/trpc_agent_sdk/agents/core/_code_execution_processor.py b/trpc_agent_sdk/agents/core/_code_execution_processor.py new file mode 100644 index 000000000..2287694f4 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_code_execution_processor.py @@ -0,0 +1,439 @@ +# -*- coding: utf-8 -*- +# +# Copyright @ 2025 Tencent.com +"""Code execution processor for TRPC Agent framework. + +This module provides code execution processing capabilities for LLM agents, +including pre-processing requests and post-processing responses to handle +code execution. +""" + +from __future__ import annotations + +import copy +import dataclasses +import os +import re +from typing import AsyncGenerator +from typing import Optional + +from google.genai.types import Outcome +from trpc_agent_sdk.code_executors import BaseCodeExecutor +from trpc_agent_sdk.code_executors import CodeBlock +from trpc_agent_sdk.code_executors import CodeExecutionInput +from trpc_agent_sdk.code_executors import CodeExecutionResult +from trpc_agent_sdk.code_executors import CodeExecutionUtils +from trpc_agent_sdk.code_executors import CodeExecutorContext +from trpc_agent_sdk.code_executors import CodeFile +from trpc_agent_sdk.code_executors import ContainerCodeExecutor +from trpc_agent_sdk.code_executors import UnsafeLocalCodeExecutor +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import EventActions +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from .._base_agent import BaseAgent + + +@dataclasses.dataclass +class DataFileUtil: + """A structure that contains a data file name and its content.""" + + extension: str + """The file extension (e.g., ".csv").""" + + loader_code_template: str + """The code template to load the data file.""" + + +_DATA_FILE_UTIL_MAP = { + "text/csv": DataFileUtil( + extension=".csv", + loader_code_template="pd.read_csv('{filename}')", + ), +} + +_DATA_FILE_HELPER_LIB = ''' +import pandas as pd + +def explore_df(df: pd.DataFrame) -> None: + """Prints some information about a pandas DataFrame.""" + + with pd.option_context( + 'display.max_columns', None, 'display.expand_frame_repr', False + ): + # Print the column names to never encounter KeyError when selecting one. + df_dtypes = df.dtypes + + # Obtain information about data types and missing values. + df_nulls = (len(df) - df.isnull().sum()).apply( + lambda x: f'{x} / {df.shape[0]} non-null' + ) + + # Explore unique total values in columns using `.unique()`. + df_unique_count = df.apply(lambda x: len(x.unique())) + + # Explore unique values in columns using `.unique()`. + df_unique = df.apply(lambda x: crop(str(list(x.unique())))) + + df_info = pd.concat( + ( + df_dtypes.rename('Dtype'), + df_nulls.rename('Non-Null Count'), + df_unique_count.rename('Unique Values Count'), + df_unique.rename('Unique Values'), + ), + axis=1, + ) + df_info.index.name = 'Columns' + print(f"""Total rows: {df.shape[0]} +Total columns: {df.shape[1]} + +{df_info}""") +''' + + +class CodeExecutionRequestProcessor: + """Processes code execution requests.""" + + @staticmethod + async def run_async(invocation_context: InvocationContext, llm_request: LlmRequest) -> AsyncGenerator[Event, None]: + """Process code execution requests asynchronously. + + Args: + invocation_context: The invocation context. + llm_request: The LLM request to process. + + Yields: + Events generated during processing. + """ + if not isinstance(invocation_context.agent, BaseAgent): + return + if not invocation_context.agent.code_executor: + return + + async for event in _run_pre_processor(invocation_context, llm_request): + yield event + + # Convert the code execution parts to text parts. + if not isinstance(invocation_context.agent.code_executor, BaseCodeExecutor): + return + for content in llm_request.contents: + CodeExecutionUtils.convert_code_execution_parts( + content, + invocation_context.agent.code_executor.code_block_delimiters[-1], + invocation_context.agent.code_executor.execution_result_delimiters[-1], + ) + + +class CodeExecutionResponseProcessor: + """Processes code execution responses.""" + + @staticmethod + async def run_async(invocation_context: InvocationContext, + llm_response: LlmResponse) -> AsyncGenerator[Event, None]: + """Process code execution responses asynchronously. + + Args: + invocation_context: The invocation context. + llm_response: The LLM response to process. + + Yields: + Events generated during processing. + """ + # Skip if the response is partial (streaming). + if llm_response.partial: + return + + async for event in _run_post_processor(invocation_context, llm_response): + yield event + + +async def _run_pre_processor( + invocation_context: InvocationContext, + llm_request: LlmRequest, +) -> AsyncGenerator[Event, None]: + """Pre-process the user message by adding data file processing.""" + if not isinstance(invocation_context.agent, BaseAgent): + return + + agent = invocation_context.agent + code_executor = agent.code_executor + + if not code_executor or not isinstance(code_executor, BaseCodeExecutor): + return + + # For container and unsafe local executors, we don't need to process the request + # as they handle execution directly + if isinstance(code_executor, (ContainerCodeExecutor, UnsafeLocalCodeExecutor)): + return + + if not code_executor.optimize_data_file: + return + + code_executor_context = CodeExecutorContext(invocation_context.session.state) + + # Skip if the error count exceeds the max retry attempts. + if code_executor_context.get_error_count(invocation_context.invocation_id) >= code_executor.error_retry_attempts: + return + + # [Step 1] Extract data files from the session_history and store them in + # memory. Meanwhile, mutate the inline data file to text part in session + # history from all turns. + all_input_files = _extract_and_replace_inline_files(code_executor_context, llm_request) + + # [Step 2] Run Explore_Df code on the data files from the current turn. We + # only need to explore the new data files because the previous data files + # should already be explored and cached in the code execution runtime. + processed_file_names = set(code_executor_context.get_processed_file_names()) + files_to_process = [f for f in all_input_files if f.name not in processed_file_names] + for file in files_to_process: + code_str = _get_data_file_preprocessing_code(file) + # Skip for unsupported file or executor types. + if not code_str: + return + + # Emit the code to execute, and add it to the LLM request. + code_content = Content( + role="model", + parts=[ + Part(text=f"Processing input file: `{file.name}`"), + CodeExecutionUtils.build_executable_code_part(code_str), + ], + ) + llm_request.contents.append(copy.deepcopy(code_content)) + yield Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=code_content, + ) + + code_execution_result = await code_executor.execute_code( + invocation_context, + CodeExecutionInput( + code_blocks=[CodeBlock(language="python", code=code_str)], + input_files=[file], + execution_id=_get_or_set_execution_id(invocation_context, code_executor_context), + ), + ) + # Update the processing results to code executor context. + code_executor_context.update_code_execution_result( + invocation_context.invocation_id, + [CodeBlock(language="python", code=code_str)], + code_execution_result, + ) + code_executor_context.add_processed_file_names([file.name]) + + # Emit the execution result, and add it to the LLM request. + execution_result_event = await _post_process_code_execution_result(invocation_context, code_executor_context, + code_execution_result) + yield execution_result_event + llm_request.contents.append(copy.deepcopy(execution_result_event.content)) + + +async def _run_post_processor( + invocation_context: InvocationContext, + llm_response: LlmResponse, +) -> AsyncGenerator[Event, None]: + """Post-process the model response by extracting and executing the first code block.""" + agent = invocation_context.agent + code_executor = agent.code_executor + + if not code_executor or not isinstance(code_executor, BaseCodeExecutor): + return + if not llm_response or not llm_response.content: + return + + code_executor_context = CodeExecutorContext(invocation_context.session.state) + # Skip if the error count exceeds the max retry attempts. + if code_executor_context.get_error_count(invocation_context.invocation_id) >= code_executor.error_retry_attempts: + return + + # [Step 1] Extract code from a cloned response content. + # IMPORTANT: extract_code_and_truncate_content mutates the input Content in place. + # Clone first to avoid mutating shared references that are later used by + # telemetry tracing and session persistence. + response_content = copy.deepcopy(llm_response.content) + code_blocks = CodeExecutionUtils.extract_code_and_truncate_content(response_content, + code_executor.code_block_delimiters, + code_executor.ignore_codes) + # Terminal state: no code to execute. + if not code_blocks: + return + + # [Step 2] Execute the code and generate events + code_execution_result = await code_executor.execute_code( + invocation_context, + CodeExecutionInput( + code_blocks=code_blocks, + input_files=code_executor_context.get_input_files(), + execution_id=_get_or_set_execution_id(invocation_context, code_executor_context), + ), + ) + + # Update the processing results to code executor context. + code_executor_context.update_code_execution_result( + invocation_context.invocation_id, + code_blocks, + code_execution_result, + ) + + # Generate events for code execution results + # Event 1: Code execution event + parts = [Part.from_executable_code(code=code_block.code, language='PYTHON') for code_block in code_blocks] + code_execution_event = Event( + invocation_id=invocation_context.invocation_id, + author=agent.name, + branch=invocation_context.branch, + content=Content(role="model", parts=parts), + actions=EventActions(), + ) + yield code_execution_event + + # Event 2: Code execution result event + result_event = await _post_process_code_execution_result(invocation_context, code_executor_context, + code_execution_result) + yield result_event + + # [Step 3] Skip executable code parts to continue the code generation loop, + # while preserving: + # 1) text parts (after truncation/extraction) for conversation memory + # 2) function_call parts for downstream function_call/function_response pairing + retained_parts: list[Part] = [] + + # Keep text parts from the transformed response content (code stripped out). + if response_content and response_content.parts: + retained_parts.extend([copy.deepcopy(part) for part in response_content.parts if part.text]) + + # Keep original function_call parts from the original response payload. + if llm_response.content and llm_response.content.parts: + retained_parts.extend([copy.deepcopy(part) for part in llm_response.content.parts if part.function_call]) + + if retained_parts: + llm_response.content = Content( + role=llm_response.content.role if llm_response.content else "model", + parts=retained_parts, + ) + else: + llm_response.content = None + + +def _extract_and_replace_inline_files( + code_executor_context: CodeExecutorContext, + llm_request: LlmRequest, +) -> list[CodeFile]: + """Extracts and replaces inline files with file names in the LLM request.""" + all_input_files = code_executor_context.get_input_files() + saved_file_names = set(f.name for f in all_input_files) + + # [Step 1] Process input files from LlmRequest and cache them in CodeExecutor. + for i in range(len(llm_request.contents)): + content = llm_request.contents[i] + # Only process the user message. + if content.role != "user" and not content.parts: + continue + + for j in range(len(content.parts)): + part = content.parts[j] + # Skip if the inline data is not supported. + if not part.inline_data or part.inline_data.mime_type not in _DATA_FILE_UTIL_MAP: + continue + + # Replace the inline data file with a file name placeholder. + mime_type = part.inline_data.mime_type + file_name = f"data_{i + 1}_{j + 1}{_DATA_FILE_UTIL_MAP[mime_type].extension}" + llm_request.contents[i].parts[j] = Part(text="\nAvailable file: `%s`\n" % file_name) + + # Add the inline data as input file to the code executor context. + file = CodeFile( + name=file_name, + content=CodeExecutionUtils.get_encoded_file_content(part.inline_data.data).decode(), + mime_type=mime_type, + ) + if file_name not in saved_file_names: + code_executor_context.add_input_files([file]) + all_input_files.append(file) + + return all_input_files + + +def _get_or_set_execution_id( + invocation_context: InvocationContext, + code_executor_context: CodeExecutorContext, +) -> Optional[str]: + """Returns the ID for stateful code execution or None if not stateful.""" + if not invocation_context.agent.code_executor.stateful: + return None + + execution_id = code_executor_context.get_execution_id() + if not execution_id: + execution_id = invocation_context.session.id + code_executor_context.set_execution_id(execution_id) + return execution_id + + +async def _post_process_code_execution_result( + invocation_context: InvocationContext, + code_executor_context: CodeExecutorContext, + code_execution_result: CodeExecutionResult, +) -> Event: + """Post-process the code execution result and emit an Event.""" + + # Handle code execution error retry. + is_ok = code_execution_result.outcome == Outcome.OUTCOME_OK + if not is_ok: + code_executor_context.increment_error_count(invocation_context.invocation_id) + else: + code_executor_context.reset_error_count(invocation_context.invocation_id) + + result_content = Content( + role="user", + parts=[ + Part.from_code_execution_result(outcome=code_execution_result.outcome, output=code_execution_result.output), + ], + ) + event_actions = EventActions(state_delta=code_executor_context.get_state_delta()) + + # Note: Artifact service operations are not supported in TRPC Agent yet. + # Output files from code execution are not saved as artifacts. + + return Event( + invocation_id=invocation_context.invocation_id, + author=invocation_context.agent.name, + branch=invocation_context.branch, + content=result_content, + actions=event_actions, + ) + + +def _get_data_file_preprocessing_code(file: CodeFile) -> Optional[str]: + """Returns the code to explore the data file.""" + + def _get_normalized_file_name(file_name: str) -> str: + var_name, _ = os.path.splitext(file_name) + # Replace non-alphanumeric characters with underscores + var_name = re.sub(r"[^a-zA-Z0-9_]", "_", var_name) + + # If the filename starts with a digit, prepend an underscore + if var_name[0].isdigit(): + var_name = "_" + var_name + return var_name + + if file.mime_type not in _DATA_FILE_UTIL_MAP: + return + + var_name = _get_normalized_file_name(file.name) + loader_code = _DATA_FILE_UTIL_MAP[file.mime_type].loader_code_template.format(filename=file.name) + return f""" +{_DATA_FILE_HELPER_LIB} + +# Load the dataframe. +{var_name} = {loader_code} + +# Use `explore_df` to guide my analysis. +explore_df({var_name}) +""" diff --git a/trpc_agent_sdk/agents/core/_history_processor.py b/trpc_agent_sdk/agents/core/_history_processor.py new file mode 100644 index 000000000..f8b90f6dc --- /dev/null +++ b/trpc_agent_sdk/agents/core/_history_processor.py @@ -0,0 +1,359 @@ +# 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. +"""History message control processor for TRPC Agent framework. + +This module provides history filtering and processing logic for LlmAgent, +including timeline-based filtering, branch-based filtering, and role preservation. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger + +# Constant key for storing calculated branch in custom_metadata +_TRPC_USER_MESSAGE_BRANCH = "_trpc_user_message_branch" + + +class TimelineFilterMode(str, Enum): + """Timeline filter mode for conversation history. + + Controls which historical messages are included based on temporal scope. + """ + + ALL = "all" + """Include all historical messages regardless of when they were created.""" + + INVOCATION = "invocation" + """Only include messages from the current invocation (same runner.run_async() call).""" + + +class BranchFilterMode(str, Enum): + """Branch filter mode for conversation history. + + Controls which historical messages are included based on agent branch relationships. + """ + + PREFIX = "prefix" + """Include messages where the event's branch is a prefix of the current branch.""" + + ALL = "all" + """Include messages from all branches regardless of relationship.""" + + EXACT = "exact" + """Only include messages from the exact same branch.""" + + +class HistoryProcessor: + """Processor for filtering and managing conversation history. + + This class encapsulates all history filtering logic including: + - Timeline-based filtering (by invocation_id) + - Branch-based filtering (by branch relationships) + - Max history runs limiting + """ + + def __init__( + self, + max_history_messages: int = 0, + timeline_filter_mode: TimelineFilterMode = TimelineFilterMode.ALL, + branch_filter_mode: BranchFilterMode = BranchFilterMode.ALL, + ): + """Initialize HistoryProcessor with filtering parameters. + + Args: + max_history_messages: Maximum number of history messages (0 = no limit) + timeline_filter_mode: Timeline filter mode + branch_filter_mode: Branch filter mode + """ + self.max_history_messages = max_history_messages + self.timeline_filter_mode = timeline_filter_mode + self.branch_filter_mode = branch_filter_mode + + def filter_events( + self, + ctx: InvocationContext, + events: list[Event], + ) -> list[Event]: + """Filter events based on history control policies. + + Applies filters in the following order: + 0. Tag user events with calculated branch + 1. Timeline filtering (by invocation_id) + 2. Branch filtering (by branch prefix/exact/all) + 3. Transfer-to-agent filtering (exclude transfer_to_agent events) + 4. Content filtering (non-empty content) + 5. Max history runs limiting (take last N messages) + + Note: The separate include_previous_history parameter has been removed. + Use branch_filter_mode instead: + - BranchFilterMode.ALL: Include all branches (equivalent to include_previous_history=True) + - BranchFilterMode.EXACT: Only include same branch (equivalent to include_previous_history=False) + - BranchFilterMode.PREFIX: Include ancestor/descendant branches + + Args: + ctx: Invocation context + events: List of events to filter + + Returns: + Filtered list of events + """ + current_branch = ctx.branch + + # Step 0: Tag user events with calculated branch (only for PREFIX/EXACT modes) + need_calculate_branch = self._need_calculate_user_event_branch(self.branch_filter_mode) + if need_calculate_branch: + events = self._tag_user_events_with_calculated_branch(events) + + filtered_events = [] + for event in events: + + # Step 1: Timeline filtering + if not self._should_include_event_by_timeline(event, self.timeline_filter_mode, ctx): + continue + + # Step 2: Branch filtering + if not self._should_include_event_by_branch(event, current_branch, self.branch_filter_mode): + continue + + # Step 3: Filter out transfer_to_agent events + if self._contains_transfer_to_agent(event): + continue + + # Step 4: Content filtering + if not self._should_include_event_in_contents(event): + continue + + filtered_events.append(event) + + # Step 5: Max history runs limiting (applied last) + if self.max_history_messages > 0 and len(filtered_events) > self.max_history_messages: + logger.debug("Limiting history from %s to %s events", len(filtered_events), self.max_history_messages) + + # Check if the first event after limiting contains function_response from current agent + first_element = filtered_events[-self.max_history_messages:][0] + if (first_element.branch == ctx.branch and first_element.content and first_element.content.parts + and any(part.function_response for part in first_element.content.parts)): + # Include one more event to get the corresponding function_call + filtered_events = filtered_events[-(self.max_history_messages + 1):] + logger.debug("Added previous event with function_call to maintain continuity") + else: + filtered_events = filtered_events[-self.max_history_messages:] + + # Step 6: Clean up calculated branch from custom_metadata (only if calculation was performed) + if need_calculate_branch: + self._cleanup_calculated_branch(filtered_events) + + return filtered_events + + def _should_include_event_by_timeline( + self, + event: Event, + timeline_filter_mode: TimelineFilterMode, + ctx: Optional[InvocationContext], + ) -> bool: + """Determine if an event should be included based on timeline filtering. + + Args: + event: The event to check + timeline_filter_mode: The timeline filter mode to apply + ctx: The invocation context (for invocation_id) + + Returns: + True if the event should be included, False otherwise + """ + if timeline_filter_mode == TimelineFilterMode.ALL: + return True + + # INVOCATION mode: Filter by invocation_id (which represents a single runner.run_async() call) + if timeline_filter_mode == TimelineFilterMode.INVOCATION: + if ctx and event.invocation_id: + return event.invocation_id == ctx.invocation_id + # If no invocation_id available, include by default + return True + + # Unknown mode, include by default + logger.warning("Unknown timeline filter mode: %s, including event", timeline_filter_mode) + return True + + def _should_include_event_by_branch( + self, + event: Event, + current_branch: Optional[str], + branch_filter_mode: BranchFilterMode, + ) -> bool: + """Determine if an event should be included based on branch filtering. + + Args: + event: The event to check + current_branch: The current agent's branch + branch_filter_mode: The branch filter mode to apply + + Returns: + True if the event should be included, False otherwise + """ + if branch_filter_mode == BranchFilterMode.ALL: + return True + + # Special handling for user events with calculated branch + if event.author == "user" and event.custom_metadata and _TRPC_USER_MESSAGE_BRANCH in event.custom_metadata: + calculated_branch = event.custom_metadata[_TRPC_USER_MESSAGE_BRANCH] + # For PREFIX and EXACT modes, user event is included only when + # the calculated branch equals the current agent's branch + if branch_filter_mode == BranchFilterMode.EXACT: + return calculated_branch == current_branch + elif branch_filter_mode == BranchFilterMode.PREFIX: + return (calculated_branch == current_branch or current_branch.startswith(calculated_branch + ".")) + + # If no branch information, include by default + if not current_branch or not event.branch: + return True + + if branch_filter_mode == BranchFilterMode.EXACT: + return event.branch == current_branch + + if branch_filter_mode == BranchFilterMode.PREFIX: + # Include if event's branch is a prefix of current branch (ancestor or self) + # Examples: + # - event.branch="coordinator" matches current_branch="coordinator.math_agent" (ancestor) + # - event.branch="coordinator" matches current_branch="coordinator" (self) + # - event.branch="coordinator.info_agent" does NOT match current_branch="coordinator.math_agent" (sibling) + # + # Must be exact match OR followed by a dot separator to avoid partial matches + return (event.branch == current_branch or current_branch.startswith(event.branch + ".")) + + # Unknown mode, fall back to prefix matching + logger.warning("Unknown branch filter mode: %s, using prefix mode", branch_filter_mode) + return (event.branch == current_branch or current_branch.startswith(event.branch + ".")) + + def _should_include_event_in_contents(self, event: Event) -> bool: + """Determine if an event should be included in contents. + + Filters out events with empty content. + + Args: + event: The event to check + + Returns: + True if the event should be included, False otherwise + """ + # Don't include events without content + if not event.content: + return False + + # Don't include events without parts + if not event.content.parts: + return False + + return True + + def _contains_transfer_to_agent(self, event: Event) -> bool: + """Check if event contains transfer_to_agent function calls/responses. + + Args: + event: The event to check + + Returns: + True if the event contains transfer_to_agent, False otherwise + """ + if not event.content or not event.content.parts: + return False + + for part in event.content.parts: + if part.function_call and part.function_call.name == "transfer_to_agent": + return True + if part.function_response and part.function_response.name == "transfer_to_agent": + return True + + return False + + def _need_calculate_user_event_branch(self, branch_filter_mode: BranchFilterMode) -> bool: + """Determine if user event branch calculation is needed. + + Branch calculation is only needed for PREFIX and EXACT filter modes, + as these modes require knowing which branch user events belong to. + + Args: + branch_filter_mode: The branch filter mode being used + + Returns: + True if branch calculation is needed, False otherwise + """ + return branch_filter_mode in (BranchFilterMode.PREFIX, BranchFilterMode.EXACT) + + def _tag_user_events_with_calculated_branch(self, events: list[Event]) -> list[Event]: + """Tag user events with calculated branch based on agent execution path. + + For each Event(author=user), the calculated branch is the concatenation of agent names + that executed between this user event and the next user event, formatted as + "agent1_name.agent2_name". + + If there is no next user event, the current user event should always be included + (no calculated branch needed). + + Args: + events: List of events to process + + Returns: + List of events with user events tagged with calculated branch in custom_metadata + """ + if not events: + return events + + # Find all user event indices + user_event_indices = [i for i, event in enumerate(events) if event.author == "user"] + + if not user_event_indices: + return events + + # Process each user event + for idx, user_idx in enumerate(user_event_indices): + # Check if there's a next user event + if idx + 1 < len(user_event_indices): + next_user_idx = user_event_indices[idx + 1] + + # Collect agent names between this user event and the next user event + agent_names = [] + for i in range(user_idx + 1, next_user_idx): + event = events[i] + # Only include events from agents (not user) + if event.author != "user" and event.author: + # Only add if not already in the list to avoid duplicates + if event.author not in agent_names: + agent_names.append(event.author) + + # Create calculated branch + if agent_names: + calculated_branch = ".".join(agent_names) + + # Tag the user event with calculated branch in custom_metadata + user_event = events[user_idx] + if user_event.custom_metadata is None: + user_event.custom_metadata = {} + user_event.custom_metadata[_TRPC_USER_MESSAGE_BRANCH] = calculated_branch + logger.debug("Tagged user event at index %s with calculated branch: %s", user_idx, + calculated_branch) + # else: If there's no next user event, this event should always be included (no tagging needed) + + return events + + def _cleanup_calculated_branch(self, events: list[Event]) -> None: + """Remove calculated branch from custom_metadata after filtering. + + This cleanup method removes the _TRPC_USER_MESSAGE_BRANCH key from + custom_metadata of all events after the filtering process is complete. + + Args: + events: List of events to cleanup + """ + for event in events: + if event.custom_metadata and _TRPC_USER_MESSAGE_BRANCH in event.custom_metadata: + del event.custom_metadata[_TRPC_USER_MESSAGE_BRANCH] + logger.debug("Removed calculated branch from user event %s", event.id) diff --git a/trpc_agent_sdk/agents/core/_llm_processor.py b/trpc_agent_sdk/agents/core/_llm_processor.py new file mode 100644 index 000000000..6d3ca3730 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_llm_processor.py @@ -0,0 +1,256 @@ +# 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. +"""LLM Processor implementation for TRPC Agent framework. + +This module provides the LlmProcessor class which serves as an adapter between +LlmAgent and the model system. It handles different types of LLM responses +and generates appropriate events for the agent to handle. + +The LlmProcessor is simplified to directly create Event objects from LlmResponse. +""" + +from __future__ import annotations + +import time +from typing import AsyncGenerator +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +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.planners import default_planning_processor +from trpc_agent_sdk.telemetry import report_call_llm +from trpc_agent_sdk.telemetry import trace_call_llm +from trpc_agent_sdk.telemetry import tracer + + +class LlmProcessor: + """LLM Processor for handling model communication and response processing. + + This class serves as an adapter between LlmAgent and the underlying model + system, converting model responses to unified Events. + """ + + def __init__(self, model: LLMModel): + """Initialize LlmProcessor with a specific model. + + Args: + model: The LLM model to use for processing + """ + self.model = model + + async def call_llm_async(self, + request: LlmRequest, + context: InvocationContext, + stream: bool = True) -> AsyncGenerator[Event, None]: + """Call the LLM and yield Events for each response. + + This method: + 1. Validates the request + 2. Calls the model with the request + 3. Creates Event objects directly from LlmResponse + 4. Yields unified Event objects for the agent to handle + + Args: + request: The model request to send + context: The invocation context + stream: Whether to stream responses + + Yields: + Event: Events representing the model responses + """ + author = context.agent.name + logger.debug("Starting LLM call for agent: %s", author) + + try: + # Step 1: Validate the request + try: + self.model.validate_request(request) + except ValueError as ex: + logger.error("Request validation failed for agent %s: %s", author, ex, exc_info=True) + yield self._create_error_event(context, "validation_error", f"Request validation failed: {str(ex)}") + return + except Exception as ex: # pylint: disable=broad-except + logger.error("Unexpected validation error for agent %s: %s", author, ex, exc_info=True) + yield self._create_error_event(context, "validation_unexpected_error", f"Validation error: {str(ex)}") + return + + # Step 2: Call the model and process responses with telemetry tracing. + with tracer.start_as_current_span('call_llm'): + event_id = Event.new_id() + final_llm_response = None + aggregated_raw_function_calls: list[dict] = [] + aggregated_event_function_calls: list[dict] = [] + + def _append_function_calls(target: list[dict], calls: list) -> None: + for call in calls or []: + # Keep only telemetry-safe fields for trace attributes. + target.append({ + "id": getattr(call, "id", None), + "name": getattr(call, "name", None), + "args": getattr(call, "args", None), + }) + + t_start = time.monotonic() + t_first_token: Optional[float] = None + metrics_error_type: Optional[str] = None + try: + async for llm_response in self.model.generate_async(request, stream=stream, ctx=context): + if t_first_token is None and llm_response.has_content(): + t_first_token = time.monotonic() + # Collect raw model-level function calls from every chunk. + raw_calls = [] + if llm_response.content and llm_response.content.parts: + for part in llm_response.content.parts: + if part.function_call: + raw_calls.append(part.function_call) + _append_function_calls(aggregated_raw_function_calls, raw_calls) + + # Create Event directly from LlmResponse + event = self._create_event_from_response(context, event_id, llm_response) + + # Process response with planner if available + event = self._process_planning_response(event, context) + _append_function_calls(aggregated_event_function_calls, event.get_function_calls()) + + # Create Event directly from LlmResponse + event = self._create_event_from_response(context, event_id, llm_response) + + # Process response with planner if available + event = self._process_planning_response(event, context) + + # Track the latest non-partial response for tracing + # In streaming mode, only the final (non-partial) response + # contains complete data suitable for telemetry reporting. + if not llm_response.partial: + final_llm_response = llm_response + + yield event + except Exception as ex: + metrics_error_type = type(ex).__name__ + raise + finally: + duration_s = time.monotonic() - t_start + ttft_s = (t_first_token - t_start) if t_first_token is not None else duration_s + report_call_llm( + context, + request, + final_llm_response, + duration_s=duration_s, + ttft_s=ttft_s, + is_stream=stream, + error_type=metrics_error_type, + ) + + # Trace the LLM call once after the stream completes, + # using the final complete response to avoid attribute + # overwrites from multiple partial trace_call_llm calls. + if final_llm_response is not None: + instruction_metadata = getattr(context.agent.instruction, 'metadata', None) + trace_call_llm(context, + event_id, + request, + final_llm_response, + instruction_metadata=instruction_metadata, + stream_function_calls_raw=aggregated_raw_function_calls, + stream_function_calls_post_planner=aggregated_event_function_calls) + + except Exception as ex: # pylint: disable=broad-except + logger.error("LLM call failed for agent %s: %s", author, ex) + yield self._create_error_event(context, "llm_call_error", f"LLM call failed: {str(ex)}") + + logger.debug("LLM call completed for agent: %s", author) + + def _create_event_from_response(self, ctx: InvocationContext, event_id: str, response: LlmResponse) -> Event: + """Create an Event directly from LlmResponse. + + Since Event inherits from LlmResponse, we can directly pass the LlmResponse fields + to the Event constructor along with the additional Event-specific fields. + + Args: + ctx: The invocation context containing author, invocation_id, and branch + event_id: The event ID + response: The LlmResponse to convert + + Returns: + Event: The created event + """ + # Create Event directly with all LlmResponse fields plus Event-specific fields + return Event( + # LlmResponse fields + content=response.content, + grounding_metadata=response.grounding_metadata, + partial=response.partial, + turn_complete=response.turn_complete, + error_code=response.error_code, + error_message=response.error_message, + interrupted=response.interrupted, + custom_metadata=response.custom_metadata, + usage_metadata=response.usage_metadata, + response_id=response.response_id, + # Event-specific fields extracted from context + id=event_id, + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + branch=ctx.branch, + ) + + def _create_error_event(self, ctx: InvocationContext, error_code: str, error_message: str) -> Event: + """Create an error Event. + + Args: + ctx: The invocation context + error_code: The error code + error_message: The error message + + Returns: + Event: The error event + """ + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + error_code=error_code, + error_message=error_message, + branch=ctx.branch, + ) + + def _process_planning_response(self, event: Event, context: InvocationContext) -> Event: + """Process the event using planner if available. + + Args: + event: The event to process + context: The invocation context + + Returns: + The processed event (modified if planner processing occurred) + """ + try: + # Check if agent has planner + agent = context.agent + if not hasattr(agent, 'planner') or not agent.planner: + return event + + # Only process events with content + if not event.content or not event.content.parts: + return event + + # Process response parts using planner with event for streaming support + processed_parts = default_planning_processor.process_response(event.content.parts, agent, context, event) + + if processed_parts: + # Update the event with processed parts + event.content.parts = processed_parts + logger.debug("Processed event content with planner for agent: %s", agent.name) + + return event + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing planning response for agent %s: %s", context.agent.name, ex) + # Return original event on error to avoid breaking the flow + return event diff --git a/trpc_agent_sdk/agents/core/_output_schema_processor.py b/trpc_agent_sdk/agents/core/_output_schema_processor.py new file mode 100644 index 000000000..c40c13911 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_output_schema_processor.py @@ -0,0 +1,106 @@ +# 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. +"""Output schema processor for TRPC Agent framework.""" + +from __future__ import annotations + +import json + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools import SetModelResponseTool +from trpc_agent_sdk.tools import TOOL_NAME as SET_MODEL_RESPONSE_TOOL_NAME +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +class OutputSchemaRequestProcessor: + """Processor that handles output schema for agents with tools.""" + + async def run_async(self, invocation_context: InvocationContext, llm_request: LlmRequest) -> None: + """Process output schema when tools are also present.""" + from trpc_agent_sdk.agents import LlmAgent + + agent = invocation_context.agent + if not isinstance(agent, LlmAgent): + return + + # Check if we need the processor: must have BOTH output_schema AND tools + # If no tools, the native set_output_schema is used in _request_processor + if not agent.output_schema or not agent.tools: + return + + # Check if tool already in request (from previous processor run in same request) + # We check llm_request.tools_dict instead of agent.tools to avoid mutating the agent + tool_injected = False + if llm_request.tools_dict: + tool_injected = SET_MODEL_RESPONSE_TOOL_NAME in llm_request.tools_dict + + if not tool_injected: + # Add the set_model_response tool to the REQUEST, not agent.tools + # This ensures we don't mutate the agent object and avoid duplicate tools + set_response_tool = SetModelResponseTool(agent.output_schema) + agent.tools.append(set_response_tool) + llm_request.append_tools([set_response_tool]) + + # Add instruction about using the set_model_response tool + instruction = ("IMPORTANT: You have access to other tools, but you must provide " + "your final response using the set_model_response tool with the " + "required structured format. After using any other tools needed " + "to complete the task, always call set_model_response with your " + "final answer in the specified schema format.\n\n" + "CRITICAL: When calling set_model_response, provide the fields " + "directly as parameters (not nested in objects). The schema expects " + "flat field names matching the output schema definition.") + llm_request.append_instructions([instruction]) + + logger.debug("Added output schema processor for agent: %s", agent.name) + + +# Create a default instance for convenience +default_output_schema_processor = OutputSchemaRequestProcessor() + + +def create_final_model_response_event(invocation_context: InvocationContext, json_response: str) -> Event: + """Create a final model response event from structured JSON response. + + Args: + invocation_context: The invocation context. + json_response: The JSON response from set_model_response tool. + + Returns: + A new Event that looks like a normal model response. + """ + # Create a proper model response event + final_event = Event( + invocation_id=invocation_context.invocation_id, + author=invocation_context.agent.name, + branch=invocation_context.branch, + ) + final_event.content = Content(role="model", parts=[Part(text=json_response)]) + return final_event + + +def get_structured_model_response(function_response_event: Event) -> str | None: + """Extract structured model response from function response event. + + Args: + function_response_event: The function response event to check. + + Returns: + JSON response string if set_model_response was called, None otherwise. + """ + if not function_response_event or not function_response_event.get_function_responses(): + return None + + for func_response in function_response_event.get_function_responses(): + if func_response.name == SET_MODEL_RESPONSE_TOOL_NAME: + # Convert dict to JSON string + return json.dumps(func_response.response, ensure_ascii=False) + + return None diff --git a/trpc_agent_sdk/agents/core/_request_processor.py b/trpc_agent_sdk/agents/core/_request_processor.py new file mode 100644 index 000000000..ad3c42298 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_request_processor.py @@ -0,0 +1,999 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright @ 2025 Tencent.com +"""Request Processor implementation for TRPC Agent framework. + +This module provides the RequestProcessor class which handles building LlmRequest +objects from agent configuration and invocation context. It centralizes all the +request building logic that was previously scattered in BaseAgent. + +The RequestProcessor is responsible for: +- Creating base LlmRequest objects +- Adding instructions (global + agent-specific) +- Adding tool declarations via ToolsProcessor +- Adding conversation history from session +- Adding current user content +- Handling tool response follow-up requests + +""" + +from __future__ import annotations + +import copy +import inspect +import re +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.planners import default_planning_processor +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.tools import transfer_to_agent +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Part + +from .._base_agent import BaseAgent +from ._history_processor import BranchFilterMode +from ._history_processor import HistoryProcessor +from ._history_processor import TimelineFilterMode +from ._skill_processor import SkillsRequestProcessor +from ._skill_processor import get_skill_processor_parameters +from ._skills_tool_result_processor import SkillsToolResultRequestProcessor +from ._skills_tool_result_processor import get_skill_tool_result_processor_parameters +from ._tools_processor import ToolsProcessor +from ._workspace_exec_processor import WorkspaceExecRequestProcessor +from ._workspace_exec_processor import get_workspace_exec_processor_parameters + + +class RequestProcessor: + """Processor for building LlmRequest objects from agent configuration. + + This class centralizes all request building logic and provides a clean + interface for constructing LlmRequests from agent configuration and + invocation context. Instead of raising exceptions, it returns Events + to maintain consistency with the framework's event-driven architecture. + """ + + def _create_error_event(self, ctx: InvocationContext, error_code: str, error_message: str) -> Event: + """Create an error event with the agent name from context. + + Args: + ctx: The invocation context containing agent information + error_code: The error code for the event + error_message: The error message for the event + + Returns: + Event: Error event with proper attribution + """ + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + error_code=error_code, + error_message=error_message, + ) + + async def build_request( + self, + request: LlmRequest, + agent: BaseAgent, + ctx: InvocationContext, + override_messages: Optional[List[Content]] = None, + ) -> Event: + """Build a model request from the agent configuration and context. + + This method orchestrates all the request building steps: + 1. Sets generate content config on the request + 2. Adds instructions (global + agent-specific) + 3. Adds tool declarations if tools are available + 4. Adds agent transfer capabilities if needed + 5. Adds conversation history (includes current user message in correct order) + 6. Processes planning if planner is available + + Note: Model name should be set by the caller (e.g., LlmAgent) before calling this method. + + Args: + request: The LlmRequest object to populate (model name should be already set) + agent: The BaseAgent to build the request for + ctx: The invocation context + override_messages: If provided, use these messages instead of + building from session history. Used by TeamAgent + to control member agent context. + + Returns: + Event: Success event if request building succeeds, error event if it fails + """ + # 1. Set generate content config on the request + error_event = self._set_generate_content_config(request, agent, ctx) + if error_event: + return error_event + + # 2. Add instructions (global + agent-specific) + error_event = await self._add_instructions_to_request(agent, ctx, request) + if error_event: + return error_event + + # 3. Add tool declarations if available + error_event = await self._add_tools_to_request(agent, ctx, request) + if error_event: + return error_event + + # 4. Add agent transfer capabilities if needed + error_event = await self._add_agent_transfer_capabilities(agent, ctx, request) + if error_event: + return error_event + + # 5. Add skills to the request + skill_parameters = get_skill_processor_parameters(ctx.agent_context) + error_event = await self._add_skills_to_request(agent, ctx, request, skill_parameters) + if error_event: + return error_event + + # 6. Add workspace_exec guidance (after skills, before history) + workspace_exec_parameters = get_workspace_exec_processor_parameters(ctx.agent_context) + error_event = await self._add_workspace_exec_guidance(agent, ctx, request, workspace_exec_parameters) + if error_event: + return error_event + + # 7. Add conversation history (includes current user message in correct order) + if override_messages is not None: + # Use provided messages directly (for TeamAgent member control) + for content in override_messages: + request.contents.append(content) + logger.debug("Used %s override messages for agent: %s", len(override_messages), agent.name) + elif agent.include_contents == 'default': + max_history_messages = getattr(agent, 'max_history_messages', 0) + timeline_filter_mode = getattr(agent, 'message_timeline_filter_mode', TimelineFilterMode.ALL) + branch_filter_mode = agent._get_effective_branch_filter_mode() + + error_event = await self._add_conversation_history( + agent, + ctx, + request, + max_history_messages, + timeline_filter_mode, + branch_filter_mode, + ) + if error_event: + return error_event + + # 8. Materialize loaded-skill content into tool results (post-history). + skill_tool_result_parameters = get_skill_tool_result_processor_parameters(ctx.agent_context) + error_event = await self._add_skills_tool_results(agent, ctx, request, skill_tool_result_parameters) + if error_event: + return error_event + + # 9. Process planning if planner is available + error_event = await self._add_planning_capabilities(agent, ctx, request) + if error_event: + return error_event + + # 10. Process output schema if needed (when tools are also present) + error_event = await self._add_output_schema_capabilities(agent, ctx, request) + if error_event: + return error_event + + logger.debug("Built model request for agent: %s, request: %s", agent.name, request) + + def _set_generate_content_config(self, request: LlmRequest, agent: BaseAgent, + ctx: InvocationContext) -> Optional[Event]: + """Set the generate content config on the request. + + Args: + request: The model request to update + agent: The BaseAgent to get config from + ctx: The invocation context + + Returns: + Event: Error event if config setting fails, None if successful + """ + try: + # Set config similar to adk-python basic.py + request.config = (agent.generate_content_config.model_copy( + deep=True) if agent.generate_content_config else GenerateContentConfig()) + + # If agent has output_schema, set it on the config + if agent.output_schema and hasattr(agent, 'tools') and not agent.tools: + # Set output schema on the llm request's parameter. It needs the model support. + request.set_output_schema(agent.output_schema) + + return None # Success + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error setting generate content config for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "config_error", f"Failed to set generate content config: {str(ex)}") + + async def _add_instructions_to_request(self, agent: BaseAgent, ctx: InvocationContext, + request: LlmRequest) -> Optional[Event]: + """Add global and agent instructions to the model request. + + Args: + agent: The BaseAgent to get instructions from + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if instruction resolution fails, None if successful + """ + instructions_parts = [] + + # Add global instruction from root agent (if exists) + root_agent = agent.root_agent + if root_agent and root_agent.global_instruction: + try: + global_instruction = await self._resolve_global_instruction(root_agent, ctx) + if global_instruction: + instructions_parts.append(global_instruction) + logger.debug("Added global instruction from root agent: %s", root_agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error resolving global instruction: %s", ex) + return self._create_error_event(ctx, "global_instruction_error", + f"Failed to resolve global instruction: {str(ex)}") + + # Add agent-specific instruction + if agent.instruction: + try: + # Only add agent name to instruction if add_name_to_instruction is True + if getattr(agent, 'add_name_to_instruction', True): + instructions_parts.append(f"You are an agent who's name is [{agent.name}].") + agent_instruction = await self._resolve_instruction(agent, ctx) + if agent_instruction: + instructions_parts.append(agent_instruction) + logger.debug("Added agent instruction for: %s", agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error resolving agent instruction: %s", ex) + return self._create_error_event(ctx, "agent_instruction_error", + f"Failed to resolve agent instruction: {str(ex)}") + + # Add code executor instruction if code_executor is enabled + if agent.code_executor: + code_executor_instruction = ( + "# NOTICE\n" + "YOU SHOULD NOT GENERATE CODE EXECUTION RESULT WHICH ARE PREFIXED WITH " + "CODE EXECUTION RESULT(DON'T SHOW THIS TEXT): \n" + "YOU SHOULD NOT EXPLAIN THE PROCESS OF GENERATED CODE.\n" + "YOU CAN SUMMARIZE THE CODE EXECUTION RESULT WHEN YOU GOT IT, " + "BUT ONLY NEED TO MAKE IT SIMPLE AND CLEAR.\n" + "THE OUTPUT OF CODE EXECUTION RESULT SHOULD BE PRINTED OUT IN THE LAST LINE.\n\n") + instructions_parts.append(code_executor_instruction) + logger.debug("Added code executor instruction for agent: %s", agent.name) + + summary_text = await ctx.session_service.get_session_summary(ctx.session) + if summary_text: + instructions_parts.append(f"Here is a brief summary of your previous interactions: {summary_text}") + logger.debug("Added session summary to request: %s", summary_text) + + # Build and set system prompt if we have instructions + if instructions_parts: + try: + # Combine instructions with newlines + combined_instructions = "\n\n".join(instructions_parts) + + # Set system prompt using append_instructions + request.append_instructions([combined_instructions]) + + logger.debug("Set system prompt with %s instruction parts", len(instructions_parts)) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error setting system prompt: %s", ex) + return self._create_error_event(ctx, "system_prompt_error", f"Failed to set system prompt: {str(ex)}") + + return None # Success + + async def _add_tools_to_request(self, agent: BaseAgent, ctx: InvocationContext, + request: LlmRequest) -> Optional[Event]: + """Add tool declarations to the model request. + + Args: + agent: The BaseAgent to get tools from + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if tool processing fails, None if successful + """ + # Prepare tools list - start with agent's existing tools + tools_to_process = agent.tools.copy() if agent.tools else [] + + # Add transfer tool if agent transfer should be enabled + if agent._should_enable_agent_transfer(): + try: + transfer_tool = FunctionTool(transfer_to_agent) + tools_to_process.append(transfer_tool) + logger.debug("Added transfer_to_agent tool for agent: %s", agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error adding transfer tool for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "transfer_tool_error", f"Failed to add transfer tool: {str(ex)}") + + # Process all tools if we have any + if tools_to_process: + try: + tools_processor = ToolsProcessor(tools_to_process) + await tools_processor.process_llm_request(ctx, request) + logger.debug("Processed %s tools for agent: %s", len(tools_to_process), agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing tools for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "tool_processing_error", f"Failed to process tools: {str(ex)}") + + return None # Success + + async def _add_skills_to_request(self, agent: BaseAgent, ctx: InvocationContext, request: LlmRequest, + parameters: dict[str, Any]) -> Optional[Event]: + """Add skills to the model request. + + Args: + agent: The BaseAgent to get skills from + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if skill processing fails, None if successful + """ + skill_repository = getattr(agent, 'skill_repository', None) + if skill_repository: + try: + skills_processor = SkillsRequestProcessor(skill_repository, **parameters) + skill_names = await skills_processor.process_llm_request(ctx, request) + logger.debug("Processed %s skills for agent: %s", len(skill_names), agent.name) + return None # Success + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing skills for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "skill_processing_error", f"Failed to process skills: {str(ex)}") + return None + + async def _add_workspace_exec_guidance(self, agent: BaseAgent, ctx: InvocationContext, request: LlmRequest, + parameters: dict[str, Any]) -> Optional[Event]: + """Inject workspace_exec guidance after skills and before history.""" + try: + skill_repository = getattr(agent, "skill_repository", None) + repo_resolver = parameters.get("repo_resolver") + processor = WorkspaceExecRequestProcessor( + has_skills_repo=bool(skill_repository), + repo_resolver=repo_resolver if callable(repo_resolver) else None, + ) + await processor.process_llm_request(ctx, request) + return None + except Exception as ex: # pylint: disable=broad-except + logger.error("Error injecting workspace_exec guidance for agent %s: %s", agent.name, ex) + return self._create_error_event( + ctx, + "workspace_exec_guidance_error", + f"Failed to inject workspace_exec guidance: {str(ex)}", + ) + + async def _add_skills_tool_results(self, agent: BaseAgent, ctx: InvocationContext, request: LlmRequest, + parameters: dict[str, Any]) -> Optional[Event]: + """Materialize loaded skills into tool results after history is attached.""" + if not parameters.get("tool_result_mode"): + return None + skill_repository = getattr(agent, "skill_repository", None) + if skill_repository is None: + return None + try: + processor = SkillsToolResultRequestProcessor( + skill_repository, + skip_fallback_on_session_summary=parameters.get("skip_fallback_on_session_summary", True), + repo_resolver=parameters.get("repo_resolver"), + ) + await processor.process_llm_request(ctx, request) + return None + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing skill tool results for agent %s: %s", agent.name, ex) + return self._create_error_event( + ctx, + "skill_tool_result_processing_error", + f"Failed to process skill tool results: {str(ex)}", + ) + + async def _add_agent_transfer_capabilities(self, agent: BaseAgent, ctx: InvocationContext, + request: LlmRequest) -> Optional[Event]: + """Add agent transfer capabilities to the model request if needed. + + Args: + agent: The BaseAgent to check for transfer capabilities + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if transfer processing fails, None if successful + """ + # Only add transfer capabilities if the agent should support transfers + if agent._should_enable_agent_transfer(): + try: + from ._agent_transfer_processor import default_agent_transfer_processor + + error_event = await default_agent_transfer_processor.process_agent_transfer(request, agent, ctx) + if error_event: + return error_event + logger.debug("Added agent transfer capabilities for agent: %s", agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error adding agent transfer capabilities for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "agent_transfer_setup_error", + f"Failed to add agent transfer capabilities: {str(ex)}") + + return None # Success + + async def _add_conversation_history(self, + agent: BaseAgent, + ctx: InvocationContext, + request: LlmRequest, + max_history_messages: int = 0, + timeline_filter_mode: TimelineFilterMode = TimelineFilterMode.ALL, + branch_filter_mode: BranchFilterMode = BranchFilterMode.ALL) -> Optional[Event]: + """Add conversation history to the model request. + + This method retrieves conversation history from the session and adds + it to the model request, properly handling tool calls and tool results + to enable multi-turn conversations with tools. + + Args: + agent: The BaseAgent to get history for + ctx: The invocation context + request: The model request to populate + max_history_messages: Maximum number of history messages (0 = no limit) + timeline_filter_mode: Timeline filter mode enum + branch_filter_mode: Branch filter mode enum + + Returns: + Event: Error event if history retrieval fails, None if successful + """ + try: + if not ctx.session or not ctx.session.events: + logger.debug("No conversation history to add for agent: %s", agent.name) + return None + + logger.debug("Processing %s events for conversation history", len(ctx.session.events)) + + # Get conversation contents with filtering using HistoryProcessor + history_processor = HistoryProcessor( + max_history_messages=max_history_messages, + timeline_filter_mode=timeline_filter_mode, + branch_filter_mode=branch_filter_mode, + ) + filtered_events = history_processor.filter_events( + ctx=ctx, + events=ctx.session.events, + ) + + # Process foreign agent events for conversion to user context + processed_events = [] + for event in filtered_events: + # If this is a foreign agent event (from another branch), convert it to user context + if self._is_other_agent_reply(ctx.branch, event): + event = self._convert_foreign_event(event, agent) + processed_events.append(event) + + # Rearrange events for proper function call/response pairing + processed_events = self._rearrange_events_for_async_function_responses_in_history(processed_events) + + # Merge consecutive same-role contents to avoid redundant conversation turns + merged_contents = self._merge_consecutive_same_role_contents(processed_events) + + # Add contents to request as conversation messages + for content in merged_contents: + self._add_content_to_request(request, content, agent) + + logger.debug("Added %s contents (merged from %s) to conversation history for agent: %s", + len(merged_contents), len(processed_events), agent.name) + return None # Success + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error adding conversation history for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "history_error", f"Failed to add conversation history: {str(ex)}") + + def _merge_consecutive_same_role_contents(self, events: List[Event]) -> List[Event]: + """Merge consecutive events with the same content role. + + This method combines consecutive Content objects that have the same role + to avoid redundant conversation turns in the LLM request. + + IMPORTANT: User messages from different invocation_ids (conversation turns) + are NOT merged to preserve turn boundaries. + + Args: + events: List of events to merge + + Returns: + List of events with consecutive same-role contents merged + """ + if not events: + return events + + merged_events = [] + current_event = None + + for event in events: + if not event.content or not event.content.parts: + # Event without content - add as is + if current_event: + merged_events.append(current_event) + current_event = None + merged_events.append(event) + continue + + # Determine the effective role for this event + event_role = self._get_effective_content_role(event) + + if current_event is None: + # First event with content + current_event = copy.deepcopy(event) + # Ensure role is set correctly + if current_event.content: + current_event.content.role = event_role + else: + # Check if we can merge with previous event + current_role = self._get_effective_content_role(current_event) + + if current_role == event_role: + # Same role - merge the parts + if current_event.content and event.content: + # Add all parts from the new event to the current event + current_event.content.parts.extend(event.content.parts) + else: + # Different role - finalize current and start new + merged_events.append(current_event) + current_event = copy.deepcopy(event) + # Ensure role is set correctly + if current_event.content: + current_event.content.role = event_role + + # Don't forget the last event + if current_event: + merged_events.append(current_event) + + return merged_events + + def _get_effective_content_role(self, event: Event) -> Optional[str]: + """Get the effective content role for an event. + + This method determines what role the event's content should have, + considering both explicit roles and fallback logic. + + Args: + event: The event to get the role for + + Returns: + The effective role for the event's content + """ + if not event.content: + return None + + # If role is already set, use it + if event.content.role: + return event.content.role + + # Determine role based on author and content type + if event.author == "user": + return "user" + else: + # For agent responses, check if this contains function responses or code execution results + if event.content.parts: + has_function_response = any(part.function_response for part in event.content.parts) + has_code_execution_result = any(part.code_execution_result for part in event.content.parts) + if has_function_response or has_code_execution_result: + # Function responses and code execution results should be presented as user content to the LLM + # This ensures proper role alternation (user/assistant) in multi-turn conversations + return "user" + # Regular agent text responses should be model role + return "model" + + def _convert_foreign_event(self, event: Event, agent: BaseAgent) -> Event: + """Converts an event authored by another agent as a user-content event. + + This provides another agent's output as context to the current agent. + + Note: Events containing transfer_to_agent are filtered out earlier in + HistoryProcessor, so we don't need to check for them here. + + Args: + event: The event to convert + agent: The current agent (to check add_name_to_instruction setting) + + Returns: + Converted event with agent name prefix added/removed based on configuration + """ + if not event.content or not event.content.parts: + return event + + # Check if we should include agent names in the converted content + include_agent_name = getattr(agent, 'add_name_to_instruction', True) + + # Create new content with user role + content_parts = [] + + # First, extract the meaningful content from the event + text_content = [] + tool_calls = [] + tool_responses = [] + + for part in event.content.parts: + if part.text: + text_content.append(part.text) + elif part.function_call: + tool_calls.append(part.function_call) + elif part.function_response: + tool_responses.append(part.function_response) + + # Format the content appropriately for user context + if text_content: + # For chain agents, the text content from previous agent is the main result + combined_text = " ".join(text_content).strip() + if combined_text: + # Add "[agent] said:" prefix only if add_name_to_instruction is True + if include_agent_name: + content_parts.append(Part.from_text(text=f"[{event.author}]: {combined_text}\n")) + else: + content_parts.append(Part.from_text(text=f"{combined_text}\n")) + + # Include tool information for context if needed + for tool_call in tool_calls: + if include_agent_name: + content_parts.append( + Part.from_text(text=(f"[{event.author}] called tool `{tool_call.name}`" + f" with parameters: {tool_call.args}\n"))) + else: + content_parts.append( + Part.from_text(text=(f"Called tool `{tool_call.name}`" + f" with parameters: {tool_call.args}\n"))) + + for tool_response in tool_responses: + if include_agent_name: + content_parts.append( + Part.from_text(text=(f"[{event.author}] `{tool_response.name}` tool" + f" returned result: {tool_response.response}\n"))) + else: + content_parts.append( + Part.from_text(text=(f"`{tool_response.name}` tool" + f" returned result: {tool_response.response}\n"))) + + # Create new content + new_content = Content(role="user", parts=content_parts) + + # Create new event with converted content + converted_event = copy.deepcopy(event) + converted_event.author = "user" + converted_event.content = new_content + + return converted_event + + def _rearrange_events_for_async_function_responses_in_history(self, events: List[Event]) -> List[Event]: + """Rearrange the async function_response events in the history.""" + function_call_id_to_response_events_index = {} + + for i, event in enumerate(events): + function_responses = self._get_function_responses(event) + if function_responses: + for function_response in function_responses: + if hasattr(function_response, "id"): + function_call_id = function_response.id + function_call_id_to_response_events_index[function_call_id] = i + + result_events = [] + for event in events: + if self._get_function_responses(event): + # function_response should be handled together with function_call below + continue + elif self._get_function_calls(event): + function_response_events_indices = set() + for function_call in self._get_function_calls(event): + if hasattr(function_call, "id"): + function_call_id = function_call.id + if function_call_id in function_call_id_to_response_events_index: + function_response_events_indices.add( + function_call_id_to_response_events_index[function_call_id]) + + result_events.append(event) + if not function_response_events_indices: + continue + + if len(function_response_events_indices) == 1: + result_events.append(events[next(iter(function_response_events_indices))]) + else: # Merge all async function_response as one response event + result_events.append( + self._merge_function_response_events( + [events[i] for i in sorted(function_response_events_indices)])) + continue + else: + result_events.append(event) + + return result_events + + def _merge_function_response_events(self, function_response_events: List[Event]) -> Event: + """Merges a list of function_response events into one event.""" + if not function_response_events: + raise ValueError("At least one function_response event is required.") + + merged_event = copy.deepcopy(function_response_events[0]) + parts_in_merged_event = merged_event.content.parts + + if not parts_in_merged_event: + raise ValueError("There should be at least one function_response part.") + + part_indices_in_merged_event = {} + for idx, part in enumerate(parts_in_merged_event): + if hasattr(part, "function_response") and part.function_response: + if hasattr(part.function_response, "id"): + function_call_id = part.function_response.id + part_indices_in_merged_event[function_call_id] = idx + + for event in function_response_events[1:]: + if not event.content.parts: + continue + + for part in event.content.parts: + if hasattr(part, "function_response") and part.function_response: + if hasattr(part.function_response, "id"): + function_call_id = part.function_response.id + if function_call_id in part_indices_in_merged_event: + parts_in_merged_event[part_indices_in_merged_event[function_call_id]] = part + else: + parts_in_merged_event.append(part) + part_indices_in_merged_event[function_call_id] = len(parts_in_merged_event) - 1 + else: + parts_in_merged_event.append(part) + + return merged_event + + def _get_function_calls(self, event: Event) -> List: + """Extract function calls from an event.""" + function_calls = [] + + # Check for function_call in content parts - this is where tool execution results are now stored + if event.content and event.content.parts: + for part in event.content.parts: + if hasattr(part, "function_call") and part.function_call: + # Skip transfer_to_agent function calls + if part.function_call.name == "transfer_to_agent": + continue + function_calls.append(part.function_call) + + return function_calls + + def _get_function_responses(self, event: Event) -> List: + """Extract function responses from an event.""" + function_responses = [] + if event.content and event.content.parts: + for part in event.content.parts: + if part.function_response: + # Skip transfer_to_agent function responses + if part.function_response.name == "transfer_to_agent": + continue + function_responses.append(part.function_response) + return function_responses + + def _add_content_to_request(self, request: LlmRequest, event: Event, agent: Optional['BaseAgent'] = None) -> None: + """Add a content event to the model request as appropriate Content type.""" + if not event.content or not event.content.parts: + return + + # Check if agent has a planner - if so, don't filter thought content + should_filter_thoughts = True + if agent and hasattr(agent, 'planner') and agent.planner: + should_filter_thoughts = False + logger.debug("Agent %s has planner, keeping thought content", agent.name) + + # Filter out parts where thought=True (only if agent doesn't have planner) + filtered_parts = [] + for part in event.content.parts: + if should_filter_thoughts and hasattr(part, 'thought') and part.thought is True: + # Skip parts marked as thoughts + logger.debug("Skipping thought content: %s...", part.text[:100] if part.text else 'non-text part') + continue + filtered_parts.append(part) + + # If no parts remain after filtering, don't add this content + if not filtered_parts: + logger.debug("All parts were filtered out as thoughts, skipping content") + return + + # The event.content is already a Content object, so we can add it directly + # But we need to ensure the role is set correctly based on event type + content = event.content + content.parts = filtered_parts + + # Check if role is already explicitly set (e.g., by _convert_foreign_event) + # If so, respect that role and don't override it + if content.role: + # Role already set, use it as is + pass + else: + content.role = "user" + + # Add the content to the request + request.contents.append(content) + + async def _resolve_instruction(self, agent: BaseAgent, ctx: InvocationContext) -> str: + """Resolve the instruction for the given agent. + + Args: + agent: The BaseAgent to resolve instruction for + ctx: The invocation context + + Returns: + str: The resolved instruction with template substitution applied + + Raises: + Exception: If instruction resolution fails (caught by caller) + """ + if isinstance(agent.instruction, str): + instruction = agent.instruction + else: + instruction = agent.instruction(ctx) # type: ignore + if inspect.isawaitable(instruction): + instruction = await instruction + + # Apply template substitution using session state + return self._apply_template_substitution(instruction, ctx) + + async def _resolve_global_instruction(self, root_agent: BaseAgent, ctx: InvocationContext) -> str: + """Resolve the global instruction from the root agent. + + Args: + root_agent: The root agent to get global instruction from + ctx: The invocation context + + Returns: + str: The resolved global instruction with template substitution applied + + Raises: + Exception: If global instruction resolution fails (caught by caller) + """ + if root_agent.global_instruction: + if isinstance(root_agent.global_instruction, str): + instruction = root_agent.global_instruction + else: + instruction = root_agent.global_instruction(ctx) # type: ignore + if inspect.isawaitable(instruction): + instruction = await instruction + + # Apply template substitution using session state + return self._apply_template_substitution(instruction, ctx) + return "" + + async def _add_planning_capabilities(self, agent: BaseAgent, ctx: InvocationContext, + request: LlmRequest) -> Optional[Event]: + """Add planning capabilities to the model request if planner is available. + + Args: + agent: The BaseAgent to check for planner + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if planning processing fails, None if successful + """ + # Only process planning if agent has a planner + if not hasattr(agent, 'planner') or not agent.planner: + return None + + try: + + error_event = default_planning_processor.process_request(request, agent, ctx) + if error_event: + return error_event + logger.debug("Added planning capabilities for agent: %s", agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error adding planning capabilities for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "planning_setup_error", + f"Failed to add planning capabilities: {str(ex)}") + + return None # Success + + async def _add_output_schema_capabilities(self, agent: BaseAgent, ctx: InvocationContext, + request: LlmRequest) -> Optional[Event]: + """Add output schema capabilities to the model request if needed. + + Args: + agent: The BaseAgent to check for output schema capabilities + ctx: The invocation context + request: The model request to populate + + Returns: + Event: Error event if output schema processing fails, None if successful + """ + # Only add output schema capabilities if the agent has both output_schema and tools + if hasattr(agent, "output_schema") and agent.output_schema and agent.tools: + try: + from ._output_schema_processor import default_output_schema_processor + + await default_output_schema_processor.run_async(ctx, request) + logger.debug("Added output schema capabilities for agent: %s", agent.name) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error adding output schema capabilities for agent %s: %s", agent.name, ex) + return self._create_error_event(ctx, "output_schema_setup_error", + f"Failed to add output schema capabilities: {str(ex)}") + + return None # Success + + def _is_other_agent_reply( + self, + current_branch: Optional[str], + event: Event, + ) -> bool: + """Determine if an event is a reply from another agent. + + Args: + current_branch: The current agent's branch + event: The event to check + + Returns: + True if the event is from a foreign agent and should be converted + to user context, False otherwise + """ + # User messages are never considered "other agent" + if event.author == "user": + return False + + # If no branch information, consider it "other agent" by default + if not current_branch or not event.branch: + return True + + # Return True if event is from a different branch (other agent) + return event.branch != current_branch + + def _apply_template_substitution(self, instruction: str, ctx: InvocationContext) -> str: + """Apply template substitution to replace {key} placeholders with state values. + + This method replaces template placeholders like {user:theme}, {app:config}, + {session_key}, etc. with actual values from the session state. + + This implementation is inspired by adk-python's inject_session_state but + adapted for trpc_agent's architecture. + + Args: + instruction: The instruction string containing template placeholders + ctx: The invocation context with session state + + Returns: + str: The instruction with template placeholders replaced with actual values + """ + if not instruction or '{' not in instruction: + return instruction + + # Get all state values from the session + state_dict = ctx.session.state if ctx.session else {} + + try: + # Use regex to find and replace placeholders one by one + def replace_placeholder(match): + """Replace a single placeholder with its value.""" + var_name = match.group().lstrip('{').rstrip('}').strip() + optional = False + + # Handle optional variables (ending with ?) + if var_name.endswith('?'): + optional = True + var_name = var_name.removesuffix('?') + + # Check if the variable exists in state + if var_name in state_dict: + # Convert the value to string safely + value = state_dict[var_name] + return str(value) if value is not None else "" + else: + if optional: + # Optional variable not found - return empty string + return "" + else: + # Required variable not found - leave placeholder unchanged + # This follows the behavior of the original SafeFormatter approach + return match.group() + + # This matches {variable_name} patterns including optional ones with ? + pattern = r'\{[^{}]*\}' + result = re.sub(pattern, replace_placeholder, instruction) + + logger.debug("Template substitution completed. Original: %s..., Result: %s...", instruction[:100], + result[:100]) + return result + except Exception as ex: # pylint: disable=broad-except + logger.warning("Template substitution failed for instruction: %s", ex) + # Return original instruction if formatting fails + return instruction + + +# Create a default instance for convenience +default_request_processor = RequestProcessor() diff --git a/trpc_agent_sdk/agents/core/_skill_processor.py b/trpc_agent_sdk/agents/core/_skill_processor.py new file mode 100644 index 000000000..f70213049 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_skill_processor.py @@ -0,0 +1,801 @@ +# 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. +"""SkillsRequestProcessor — injects skill overviews and loaded contents.""" + +from __future__ import annotations + +import json +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.context import AgentContext +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.skills import BaseSkillRepository +from trpc_agent_sdk.skills import Skill +from trpc_agent_sdk.skills import SkillLoadModeNames +from trpc_agent_sdk.skills import SkillProfileFlags +from trpc_agent_sdk.skills import SkillProfileNames +from trpc_agent_sdk.skills import SkillRepositoryResolver +from trpc_agent_sdk.skills import SkillToolsNames +from trpc_agent_sdk.skills import docs_scan_prefix +from trpc_agent_sdk.skills import docs_state_key +from trpc_agent_sdk.skills import get_skill_config +from trpc_agent_sdk.skills import loaded_order_state_key +from trpc_agent_sdk.skills import loaded_scan_prefix +from trpc_agent_sdk.skills import loaded_state_key +from trpc_agent_sdk.skills import marshal_loaded_order +from trpc_agent_sdk.skills import parse_loaded_order +from trpc_agent_sdk.skills import set_skill_config +from trpc_agent_sdk.skills import tool_scan_prefix +from trpc_agent_sdk.skills import tool_state_key +from trpc_agent_sdk.skills import touch_loaded_order + +from ._skills_tool_result_processor import SKILL_LOADED_RE + +# --------------------------------------------------------------------------- +# Prompt section headers (mirrors Go const block) +# --------------------------------------------------------------------------- + +_SKILLS_OVERVIEW_HEADER = "Available skills:" +_SKILLS_CAPABILITY_HEADER = "Skill tool availability:" +_SKILLS_TOOLING_GUIDANCE_HEADER = "Tooling and workspace guidance:" + +_SKILLS_TURN_INIT_STATE_KEY = "processor:skills:turn_init" + + +def normalize_load_mode(mode: str) -> str: + value = (mode or "").strip().lower() + if value in (SkillLoadModeNames.ONCE, SkillLoadModeNames.TURN, SkillLoadModeNames.SESSION): + return value + return SkillLoadModeNames.TURN + + +def _append_knowledge_guidance(lines: list[str], flags: SkillProfileFlags) -> None: + """Append docs-loading guidance mirroring Go's appendKnowledgeGuidance.""" + has_list_docs = flags.list_docs + has_select_docs = flags.select_docs + if has_list_docs and has_select_docs: + lines.append("- Use the available doc listing and selection helpers to keep" + " documentation loads targeted.\n") + elif has_list_docs: + lines.append("- Use the available doc listing helper to discover doc names," + " then load only the docs you need.\n") + elif has_select_docs: + lines.append("- If doc names are already known, use the available doc" + " selection helper to keep loaded docs targeted.\n") + else: + lines.append("- If you need docs, request them directly with skill_load.docs" + " or include_all_docs.\n") + lines.append("- Avoid include_all_docs unless the user asks or the task genuinely" + " needs the full doc set.\n") + + +# --------------------------------------------------------------------------- +# Guidance text builders (mirrors Go defaultXxxGuidance functions) +# --------------------------------------------------------------------------- + + +def _default_catalog_only_guidance() -> str: + return (f"\n{_SKILLS_TOOLING_GUIDANCE_HEADER}\n" + "- Use the skill overview as a catalog only. Built-in skill tools are" + " unavailable in this configuration; if a task depends on loading or" + " executing a skill, use other registered tools or explain the" + " limitation clearly.\n") + + +def _default_doc_helpers_only_guidance(flags: SkillProfileFlags) -> str: + lines = [ + "\n", + _SKILLS_TOOLING_GUIDANCE_HEADER, + "\n", + ] + has_list_docs = flags.list_docs + has_select_docs = flags.select_docs + if has_list_docs and has_select_docs: + lines.append("- Use skills only to inspect available doc names or adjust" + " doc selection state.\n") + elif has_list_docs: + lines.append("- Use skills only to inspect available doc names.\n") + elif has_select_docs: + lines.append("- Use skills only to adjust doc selection when doc names are" + " already known.\n") + lines.append("- Built-in skill loading is unavailable, so doc helpers do not" + " inject SKILL.md or doc contents into context; if the task needs" + " loaded content or execution, use other registered tools or" + " explain the limitation clearly.\n") + return "".join(lines) + + +def _default_knowledge_only_guidance(flags: SkillProfileFlags) -> str: + lines = [ + "\n", + _SKILLS_TOOLING_GUIDANCE_HEADER, + "\n", + "- Use skills for progressive disclosure only: load SKILL.md first," + " then inspect only the documentation needed for the current task.\n", + ] + _append_knowledge_guidance(lines, flags) + lines += [ + "- Treat loaded skill content as domain guidance. Do not claim you" + " executed scripts, shell commands, or interactive flows described by" + " the skill.\n", + "- If a skill depends on execution to complete the task, switch to" + " other registered tools (for example, MCP tools) or explain the" + " limitation clearly.\n", + ] + return "".join(lines) + + +def _default_full_tooling_and_workspace_guidance(flags: SkillProfileFlags) -> str: + lines: list[str] = [ + "\n", + _SKILLS_TOOLING_GUIDANCE_HEADER, + "\n", + "- Skills run inside an isolated workspace; you see only files that" + " are in the workspace or have been staged there by tools.\n", + "- skill_run runs with CWD at the skill root by default; avoid setting" + " cwd unless needed.\n", + "- If you set cwd, use $SKILLS_DIR/$SKILL_NAME (or a subdir)." + " $SKILLS_DIR alone is the parent dir.\n", + "- Prefer $WORK_DIR, $OUTPUT_DIR, $RUN_DIR, and $WORKSPACE_DIR over" + " hard-coded paths.\n", + "- Treat $WORK_DIR/inputs (and a skill's inputs/ directory) as the" + " place where tools stage user or host input files. Avoid overwriting" + " or mutating these inputs directly.\n", + "- User-uploaded file inputs in the conversation are automatically" + " staged into $WORK_DIR/inputs when skill_run executes.\n", + "- When the user mentions external files, directories, artifacts, or" + " URLs, decide whether to stage them into $WORK_DIR/inputs via" + " available tools before reading.\n", + "- To map external files into the workspace, use skill_run inputs" + " (artifact://, host://, workspace://, skill://). For artifacts, prefer" + " artifact://name@version; inputs[*].pin=true reuses the first resolved" + " version (best effort).\n", + "- Prefer writing new files under $OUTPUT_DIR or a skill's out/" + " directory and include output_files globs (or an outputs spec) so" + " files can be collected or saved as artifacts.\n", + "- Use stdout/stderr for logs or short status text. If the model needs" + " large or structured text, write it to files under $OUTPUT_DIR and" + " return it via output_files or outputs.\n", + "- For Python skills that need third-party packages, create a virtualenv" + " under the skill's .venv/ directory (it is writable inside the" + " workspace).\n", + "- output_files entries are workspace paths/globs (e.g. out/*.txt)." + " Do not use workspace:// or artifact:// in output_files.\n", + "- When skill_run returns primary_output or output_files, prefer using" + " the inline content directly. If you need a stable reference for other" + " tools, use output_files[*].ref (workspace://...).\n", + "- Non-text outputs never inline content. Use output_files[*].ref" + " (workspace://...) to pass them to other tools. For large text outputs," + " set omit_inline_content=true so output_files return metadata only," + " then use output_files[*].ref with read_file when needed. For" + " persistence, prefer outputs.save=true with outputs.inline=false; if" + " you use output_files, set save_as_artifacts=true.\n", + "- Do not rerun the same skill_run command when you already have the" + " needed content.\n", + "- If you already have the needed file content, stop calling file tools" + " and answer.\n", + "- When chaining multiple skills, read previous results from $OUTPUT_DIR" + " (or a skill's out/ directory) instead of copying them back into inputs" + " directories.\n", + ] + if flags.load: + lines += [ + "- Treat loaded skill docs as guidance, not perfect truth; when runtime" + " help or stderr disagrees, trust observed runtime behavior.\n", + "- Loading a skill gives you instructions and bundled resources; it does" + " not execute the skill by itself.\n", + "- The skill summaries above are routing summaries only; they do not" + " replace SKILL.md or other loaded docs.\n", + "- If the loaded content already provides enough guidance to answer or" + " produce the requested result, respond directly.\n", + "- If you decide to use a skill, load SKILL.md before", + ] + if flags.requires_exec_session_tools(): + lines.append(" the first skill_run or skill_exec for that skill, then load" + " only the docs you still need.\n") + else: + lines.append(" the first skill_run for that skill, then load only the docs" + " you still need.\n") + lines += [ + "- Do not infer commands, script entrypoints, or resource layouts from" + " the short summary alone.\n", + ] + _append_knowledge_guidance(lines, flags) + elif flags.has_doc_helpers(): + lines += [ + "- Built-in skill loading is unavailable in this configuration. Doc" + " listing or selection helpers can inspect doc names or selection" + " state, but they do not inject SKILL.md or doc contents into" + " context.\n", + ] + else: + lines += [ + "- Built-in skill loading is unavailable in this configuration; do not" + " assume SKILL.md or doc contents are in context.\n", + ] + + lines += [ + "- Use execution tools only when running a command will reveal or produce" + " information or files you still need.\n", + ] + if flags.requires_exec_session_tools(): + lines.append("- Use skill_exec only when a command needs incremental stdin or" + " TTY-style interaction; otherwise prefer one-shot execution.\n") + else: + lines.append("- Do not assume interactive execution is available when only" + " one-shot execution tools are present.\n") + lines += [ + "- skill_run is a command runner inside the skill workspace, not a magic" + " capability. It does not automatically add the skill directory to PATH" + " or install dependencies; invoke scripts via an explicit interpreter and" + " path (e.g., python3 scripts/foo.py).\n", + "- When you execute, follow the tool description, ", + ] + if flags.load: + lines[-1] += "loaded skill docs, " + lines[-1] += ("bundled scripts, and observed runtime behavior rather than inventing shell" + " syntax or command arguments.\n") + return "".join(lines) + + +def _default_tooling_and_workspace_guidance(flags: SkillProfileFlags) -> str: + if not flags.is_any(): + return _default_catalog_only_guidance() + + if not flags.run: + if flags.load: + return _default_knowledge_only_guidance(flags) + if flags.has_doc_helpers(): + return _default_doc_helpers_only_guidance(flags) + return _default_catalog_only_guidance() + return _default_full_tooling_and_workspace_guidance(flags) + + +def _normalize_custom_guidance(guidance: str) -> str: + if not guidance: + return "" + if not guidance.startswith("\n"): + guidance = "\n" + guidance + if not guidance.endswith("\n"): + guidance += "\n" + return guidance + + +# --------------------------------------------------------------------------- +# SkillsRequestProcessor +# --------------------------------------------------------------------------- + + +class SkillsRequestProcessor: + """Injects skill overviews and loaded contents into LLM requests. + + Args: + skill_repository: Default skill repository. + load_mode: ``"turn"`` (default), ``"once"``, or ``"session"``. + tooling_guidance: ``None`` → use built-in default guidance; + ``""`` → omit guidance block; + other → use provided text verbatim. + tool_result_mode: When ``True``, skip loaded-skill injection here + (content is materialized into tool results instead). + tool_profile: Profile string (e.g. ``"knowledge_only"``). + forbidden_tools: Optional explicit blacklist of built-in skill tools. + tool_flags: Optional resolved flags; when set, takes precedence + over ``tool_profile``/``forbidden_tools``. + exec_tools_disabled: When ``True``, omit skill_exec guidance lines. + repo_resolver: Optional ``(ctx) -> BaseSkillRepository`` callable + that returns an invocation-specific repository. + max_loaded_skills: Cap on simultaneously loaded skills (0 = no limit). + """ + + def __init__( + self, + skill_repository: BaseSkillRepository, + *, + load_mode: str = str(SkillLoadModeNames.TURN), + tooling_guidance: Optional[str] = None, + tool_result_mode: bool = False, + tool_profile: str = str(SkillProfileNames.FULL), + forbidden_tools: Optional[list[str]] = None, + tool_flags: Optional[SkillProfileFlags] = None, + exec_tools_disabled: bool = False, + repo_resolver: Optional[SkillRepositoryResolver] = None, + max_loaded_skills: int = 0, + ) -> None: + self._skill_repository = skill_repository + self._load_mode = normalize_load_mode(load_mode) + self._tooling_guidance = tooling_guidance + self._tool_result_mode = tool_result_mode + try: + resolved_flags = tool_flags or SkillProfileFlags.resolve_flags(tool_profile, forbidden_tools) + except ValueError as ex: + logger.warning("skills: invalid skill tool flags config, fallback to full profile: %s", ex) + resolved_flags = SkillProfileFlags.preset_flags(tool_profile, forbidden_tools) + if exec_tools_disabled: + resolved_flags = resolved_flags.without_interactive_execution() + self._tool_flags = resolved_flags + self._repo_resolver = repo_resolver + self._max_loaded_skills = max_loaded_skills + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + async def process_llm_request( + self, + ctx: InvocationContext, + request: LlmRequest, + ) -> list[str]: + """Inject skill overview and loaded content into *request*. + + Returns the list of currently-loaded skill names (after any capping). + """ + if request is None or ctx is None: + logger.warning("skills: process_llm_request: request or ctx is None") + return [] + + repo = self._get_repository(ctx) + if repo is None: + return [] + + self._maybe_clear_skill_state_for_turn(ctx) + + # 1) Always inject overview (names + descriptions). + self._inject_overview(ctx, request, repo) + + loaded = self._get_loaded_skills(ctx) + loaded = self._maybe_cap_loaded_skills(ctx, loaded) + + if self._tool_result_mode: + # Materialization is handled by a dedicated post-history processor + # in request pipeline (Go-aligned ordering). + return loaded + + # 2) Loaded skills: full body + docs (sorted for stable prompts). + loaded.sort() + + parts: list[str] = [] + for name in loaded: + try: + sk = repo.get(name) + if sk is None: + logger.warning("skills: get %s failed: skill not found", name) + continue + except Exception as ex: # pylint: disable=broad-except + logger.warning("skills: get %s failed: %s", name, ex) + continue + + if sk.body: + parts.append(f"\n[Loaded] {name}\n\n{sk.body}\n") + + # Docs + sel = self._get_docs_selection(ctx, name) + parts.append("Docs loaded: ") + if not sel: + parts.append("none\n") + else: + parts.append(", ".join(sel) + "\n") + doc_text = self._build_docs_text(sk, sel) + if doc_text: + parts.append(doc_text) + tool_sel = self._get_tools_selection(ctx, name) + parts.append("Tools selected: ") + if not tool_sel: + parts.append("none\n") + else: + parts.append(", ".join(tool_sel) + "\n") + + if parts: + self._merge_into_system(request, "".join(parts)) + + self._maybe_offload_loaded_skills(ctx, loaded) + + return loaded + + # ------------------------------------------------------------------ + # Repository + # ------------------------------------------------------------------ + + def _get_repository(self, ctx: InvocationContext) -> Optional[BaseSkillRepository]: + if self._repo_resolver is not None: + return self._repo_resolver(ctx) + return self._skill_repository + + # ------------------------------------------------------------------ + # Skill load mode: turn clearing + once offloading + # ------------------------------------------------------------------ + + def _maybe_clear_skill_state_for_turn(self, ctx: InvocationContext) -> None: + """Clear loaded-skill state at the start of each new invocation (turn mode). + + Uses ``ctx.invocation_id`` to detect when a new invocation has started + without persisting an extra key to session state. + """ + if self._load_mode != SkillLoadModeNames.TURN: + return + if ctx.agent_context.get_metadata(_SKILLS_TURN_INIT_STATE_KEY): + return + ctx.agent_context.with_metadata(_SKILLS_TURN_INIT_STATE_KEY, True) + self._clear_skill_state(ctx) + + def _clear_skill_state(self, ctx: InvocationContext) -> None: + """Clear all loaded-skill state keys from the session.""" + loaded_state_prefix = loaded_scan_prefix(ctx) + docs_state_prefix = docs_scan_prefix(ctx) + tools_state_prefix = tool_scan_prefix(ctx) + order_state_key = loaded_order_state_key(ctx) + state = self._snapshot_state(ctx) + for k, v in state.items(): + if not v: + continue + if any(( + k.startswith(loaded_state_prefix), + k.startswith(docs_state_prefix), + k.startswith(tools_state_prefix), + k == order_state_key, + )): + ctx.actions.state_delta[k] = None + + def _maybe_offload_loaded_skills(self, ctx: InvocationContext, loaded: list[str]) -> None: + """After injection, clear skill state for once mode.""" + if self._load_mode != SkillLoadModeNames.ONCE or not loaded: + return + for name in loaded: + ctx.actions.state_delta[loaded_state_key(ctx, name)] = None + ctx.actions.state_delta[docs_state_key(ctx, name)] = None + ctx.actions.state_delta[tool_state_key(ctx, name)] = None + ctx.actions.state_delta[loaded_order_state_key(ctx)] = None + + # ------------------------------------------------------------------ + # Max-loaded-skills cap + # ------------------------------------------------------------------ + + def _maybe_cap_loaded_skills(self, ctx: InvocationContext, loaded: list[str]) -> list[str]: + """Evict least-recently-used skills when over the configured cap.""" + if self._max_loaded_skills <= 0 or len(loaded) <= self._max_loaded_skills: + return loaded + + order = self._get_loaded_skill_order(ctx, loaded) + if not order: + return loaded + keep_count = self._max_loaded_skills + keep_set = set(order[-keep_count:]) + + kept: list[str] = [] + for name in loaded: + if name in keep_set: + kept.append(name) + else: + ctx.actions.state_delta[loaded_state_key(ctx, name)] = None + ctx.actions.state_delta[docs_state_key(ctx, name)] = None + ctx.actions.state_delta[tool_state_key(ctx, name)] = None + new_order = [n for n in order if n in keep_set] + encoded_order = marshal_loaded_order(new_order) + ctx.actions.state_delta[loaded_order_state_key(ctx)] = encoded_order + return kept + + def _get_loaded_skill_order(self, ctx: InvocationContext, loaded: list[str]) -> list[str]: + loaded_set = self._loaded_skill_set(loaded) + if not loaded_set: + return [] + order = self._loaded_skill_order_from_state(ctx, loaded_set) + if len(order) < len(loaded_set): + order = self._append_skills_to_order_from_events(ctx, order, loaded_set) + seen = set(order) + for name in sorted(n for n in loaded_set if n not in seen): + order.append(name) + return order + + # ------------------------------------------------------------------ + # Overview injection + # ------------------------------------------------------------------ + + def _inject_overview(self, ctx: InvocationContext, request: LlmRequest, repo: BaseSkillRepository) -> None: + sums = repo.summaries() + if not sums: + return + + # Guard against double-injection within the same invocation. + if request.config and request.config.system_instruction: + if _SKILLS_OVERVIEW_HEADER in str(request.config.system_instruction): + return + + lines: list[str] = [_SKILLS_OVERVIEW_HEADER, "\n"] + for s in sums: + lines.append(f"- {s.name}: {s.description}\n") + + capability = self._capability_guidance_text() + if capability: + lines.append(capability) + + guidance = self._tooling_guidance_text() + if guidance: + lines.append(guidance) + + overview = "".join(lines) + + # Python-unique: prepend repository-level user_prompt when present. + user_prompt = "" + if hasattr(repo, "user_prompt"): + try: + user_prompt = repo.user_prompt() or "" + except Exception: # pylint: disable=broad-except + pass + if user_prompt: + overview = f"{user_prompt}\n\n{overview}" + + request.append_instructions([overview]) + + # ------------------------------------------------------------------ + # Guidance text + # ------------------------------------------------------------------ + + def _tooling_guidance_text(self) -> str: + if self._tooling_guidance is None: + tool_prompt = _default_tooling_and_workspace_guidance(self._tool_flags) + else: + tool_prompt = _normalize_custom_guidance(self._tooling_guidance) + if self._tool_flags.has_select_tools(): + tool_prompt += """ + - Use the skill_select_tools tool to select tools for the current task only when user asks for it." + """ + if self._tool_flags.list_skills: + tool_prompt += """ + - Use the skill_list_skills tool to list skills for the current task only when user asks for it." + """ + return tool_prompt + + def _capability_guidance_text(self) -> str: + """Inject capability block for constrained skill-tool profiles.""" + # Omit when caller explicitly cleared guidance. + if self._tooling_guidance == "" or self._tool_flags.run: + return "" + if self._tool_flags.load: + return (f"\n{_SKILLS_CAPABILITY_HEADER}\n" + "- This configuration supports skill discovery and knowledge loading only.\n" + "- Built-in skill execution tools are unavailable in the current mode.\n" + "- If a loaded skill describes scripts, shell commands, workspace paths," + " generated files, or interactive flows, treat that content as reference" + " only. Use other registered tools for real actions, or explain that" + " execution is unavailable in the current mode.\n") + if self._tool_flags.has_doc_helpers(): + return (f"\n{_SKILLS_CAPABILITY_HEADER}\n" + "- This configuration supports skill discovery and skill doc inspection only.\n" + "- Built-in skill loading and execution tools are unavailable in the" + " current mode.\n- Listing or selecting docs does not inject SKILL.md or doc" + " contents into model context by itself.\n") + return (f"\n{_SKILLS_CAPABILITY_HEADER}\n" + "- This configuration exposes skill summaries only. Built-in skill tools" + " are unavailable in the current mode.\n" + "- Treat the skill overview as a catalog of possible capabilities. Use" + " other registered tools, or explain the limitation clearly when the task" + " depends on skill loading or execution.\n") + + # ------------------------------------------------------------------ + # State helpers + # ------------------------------------------------------------------ + + def _snapshot_state(self, ctx: InvocationContext) -> dict: + """Return a merged view of session state + pending delta.""" + state = dict(ctx.session_state) + for k, v in ctx.actions.state_delta.items(): + if v is None: + state.pop(k, None) + else: + state[k] = v + return state + + def _read_state(self, ctx: InvocationContext, key: str, default=None): + delta = ctx.actions.state_delta + if key in delta: + return delta[key] + return ctx.session_state.get(key, default) + + # ------------------------------------------------------------------ + # Loaded skill discovery + # ------------------------------------------------------------------ + + def _get_loaded_skills(self, ctx: InvocationContext) -> list[str]: + """Return names of all currently loaded skills.""" + names: list[str] = [] + state = self._snapshot_state(ctx) + scan_prefix = loaded_scan_prefix(ctx) + for k, v in state.items(): + if not k.startswith(scan_prefix) or not v: + continue + name = k[len(scan_prefix):].strip() + if name: + names.append(name) + if names: + return sorted(set(names)) + return [] + + # ------------------------------------------------------------------ + # Docs / tools selection + # ------------------------------------------------------------------ + + def _get_docs_selection(self, ctx: InvocationContext, name: str) -> list[str]: + value = self._read_state(ctx, docs_state_key(ctx, name), default=None) + if not value: + return [] + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + return [] + if value == "*": + repo = self._get_repository(ctx) + if repo is None: + return [] + try: + sk = repo.get(name) + return [doc.path for doc in sk.resources] + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to get docs for skill '%s': %s", name, ex) + return [] + if not isinstance(value, str): + return [] + try: + arr = json.loads(value) + except json.JSONDecodeError: + return [] + if not isinstance(arr, list): + return [] + return [doc for doc in arr if isinstance(doc, str) and doc.strip()] + + def _get_tools_selection(self, ctx: InvocationContext, name: str) -> list[str]: + value = self._read_state(ctx, tool_state_key(ctx, name), default=None) + if not value: + return [] + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + return [] + if value == "*": + repo = self._get_repository(ctx) + if repo is None: + return [] + try: + sk = repo.get(name) + return sk.tools + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to get tools for skill '%s': %s", name, ex) + return [] + if not isinstance(value, str): + return [] + try: + arr = json.loads(value) + except json.JSONDecodeError: + return [] + if not isinstance(arr, list): + return [] + return [tool for tool in arr if isinstance(tool, str) and tool.strip()] + + def _loaded_skill_set(self, loaded: list[str]) -> set[str]: + out: set[str] = set() + for name in loaded: + candidate = (name or "").strip() + if candidate: + out.add(candidate) + return out + + def _loaded_skill_order_from_state(self, ctx: InvocationContext, loaded_set: set[str]) -> list[str]: + order = parse_loaded_order(self._read_state(ctx, loaded_order_state_key(ctx))) + if not order: + return [] + out: list[str] = [] + seen: set[str] = set() + for name in order: + if name not in loaded_set or name in seen: + continue + out.append(name) + seen.add(name) + return out + + def _append_skills_to_order_from_events( + self, + ctx: InvocationContext, + order: list[str], + loaded_set: set[str], + ) -> list[str]: + events = list(getattr(ctx.session, "events", []) or []) + if not events: + return order + for event in events: + if ctx.agent_name and getattr(event, "author", "") != ctx.agent_name: + continue + content = getattr(event, "content", None) + if content is None or not getattr(content, "parts", None): + continue + for part in content.parts: + response = getattr(part, "function_response", None) + if response is None: + continue + tool_name = (getattr(response, "name", "") or "").strip() + if tool_name not in (SkillToolsNames.LOAD, SkillToolsNames.SELECT_DOCS): + continue + skill_name = self._skill_name_from_tool_response(tool_name, getattr(response, "response", None)) + if not skill_name or skill_name not in loaded_set: + continue + order = touch_loaded_order(order, skill_name) + return order + + def _skill_name_from_tool_response(self, tool_name: str, response: Any) -> str: + if tool_name == str(SkillToolsNames.SELECT_DOCS) and isinstance(response, dict): + for key in ("skill", "skill_name", "name"): + value = response.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + if tool_name == SkillToolsNames.LOAD: + if isinstance(response, dict): + for key in ("skill", "skill_name", "name", "result"): + value = response.get(key) + if isinstance(value, str) and value.strip(): + match = SKILL_LOADED_RE.search(value) + if match: + return match.group(1).strip() + if key in ("skill", "skill_name", "name"): + return value.strip() + if isinstance(response, str): + match = SKILL_LOADED_RE.search(response) + if match: + return match.group(1).strip() + return "" + + # ------------------------------------------------------------------ + # Doc text assembly + # ------------------------------------------------------------------ + + def _build_docs_text(self, sk: Skill, wanted: List[str]) -> str: + if sk is None or not sk.resources: + return "" + want = set(wanted) + parts: list[str] = [] + for d in sk.resources: + if d.path not in want or not d.content: + continue + parts.append(f"\n[Doc] {d.path}\n\n{d.content}\n") + return "".join(parts) + + # ------------------------------------------------------------------ + # System prompt merging + # ------------------------------------------------------------------ + + def _merge_into_system(self, request: LlmRequest, content: str) -> None: + """Append *content* to the system instruction.""" + if not content: + return + request.append_instructions([content]) + + +def set_skill_processor_parameters(agent_context: AgentContext, parameters: dict[str, Any]) -> None: + """Set the parameters of a skill processor by agent context. + + Args: + agent_context: AgentContext object + parameters: Parameters to set + """ + skill_config = get_skill_config(agent_context) + skill_config["skill_processor"].update(parameters) + set_skill_config(agent_context, skill_config) + + +def get_skill_processor_parameters(agent_context: AgentContext) -> dict[str, Any]: + """Get the parameters of a skill processor. + + Args: + invocation_context: InvocationContext object + + Returns: + Parameters of the skill processor + """ + skill_config = get_skill_config(agent_context) + return skill_config["skill_processor"] diff --git a/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py b/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py new file mode 100644 index 000000000..d530e3451 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_skills_tool_result_processor.py @@ -0,0 +1,378 @@ +# 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. +"""Materialize loaded skill context into skill tool results.""" + +from __future__ import annotations + +import json +import re +from typing import Any +from typing import Callable +from typing import Optional +from typing import Tuple + +from trpc_agent_sdk.context import AgentContext +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.skills import BaseSkillRepository +from trpc_agent_sdk.skills import Skill +from trpc_agent_sdk.skills import SkillLoadModeNames +from trpc_agent_sdk.skills import SkillToolsNames +from trpc_agent_sdk.skills import docs_state_key +from trpc_agent_sdk.skills import get_skill_config +from trpc_agent_sdk.skills import get_skill_load_mode +from trpc_agent_sdk.skills import loaded_order_state_key +from trpc_agent_sdk.skills import loaded_scan_prefix +from trpc_agent_sdk.skills import loaded_state_key +from trpc_agent_sdk.skills import set_skill_config +from trpc_agent_sdk.utils import json_loads_repair + +_SKILLS_LOADED_CONTEXT_HEADER = "Loaded skill context:" +_SESSION_SUMMARY_PREFIX = "Here is a brief summary of your previous interactions:" +SKILL_LOADED_RE = re.compile(r"skill\s+'([^']+)'\s+loaded", re.IGNORECASE) + + +class SkillsToolResultRequestProcessor: + """Materialize loaded skill content into skill tool results.""" + + def __init__( + self, + skill_repository: BaseSkillRepository, + *, + skip_fallback_on_session_summary: bool = True, + repo_resolver: Optional[Callable[[InvocationContext], BaseSkillRepository]] = None, + ) -> None: + self._skill_repository = skill_repository + self._repo_resolver = repo_resolver + self._skip_fallback_on_session_summary = skip_fallback_on_session_summary + + async def process_llm_request(self, ctx: InvocationContext, request: LlmRequest) -> list[str]: + """Apply loaded-skill materialization to tool results and fallback prompt.""" + if request is None or ctx is None: + return [] + repo = self._get_repository(ctx) + if repo is None: + return [] + + loaded = self._get_loaded_skills(ctx) + if not loaded: + return [] + loaded.sort() + + tool_calls = self._index_tool_calls(request) + last_tool_parts = self._last_skill_tool_parts(request, tool_calls) + + materialized: set[str] = set() + for skill_name, (content_idx, part_idx) in last_tool_parts.items(): + content = request.contents[content_idx] + part = content.parts[part_idx] + function_response = part.function_response + if function_response is None: + continue + base = self._response_to_text(getattr(function_response, "response", None)) + rendered = self._build_tool_result_content(ctx, repo, skill_name, base) + if not rendered: + continue + function_response.response = {"result": rendered} + materialized.add(skill_name) + + fallback = self._build_fallback_system_content(ctx, repo, loaded, materialized) + if fallback: + skip_fallback = False + if self._skip_fallback_on_session_summary: + skip_fallback = self._has_session_summary(request) and not last_tool_parts + if not skip_fallback: + request.append_instructions([fallback]) + + self._maybe_offload_loaded_skills(ctx, loaded) + return loaded + + def _get_repository(self, ctx: InvocationContext) -> Optional[BaseSkillRepository]: + if self._repo_resolver is not None: + return self._repo_resolver(ctx) + return self._skill_repository + + def _snapshot_state(self, ctx: InvocationContext) -> dict[str, Any]: + state = dict(ctx.session_state) + for key, value in ctx.actions.state_delta.items(): + if value is None: + state.pop(key, None) + else: + state[key] = value + return state + + def _read_state(self, ctx: InvocationContext, key: str, default=None): + if key in ctx.actions.state_delta: + return ctx.actions.state_delta[key] + return ctx.session_state.get(key, default) + + def _get_loaded_skills(self, ctx: InvocationContext) -> list[str]: + names_set: set[str] = set() + state = self._snapshot_state(ctx) + scan_prefix = loaded_scan_prefix(ctx) + for key, value in state.items(): + if not key.startswith(scan_prefix) or not value: + continue + name = key[len(scan_prefix):].strip() + if name: + names_set.add(name) + return sorted(names_set) + + def _index_tool_calls(self, request: LlmRequest) -> dict[str, Any]: + out: dict[str, Any] = {} + for content in request.contents: + if content.role not in ("model", "assistant") or not content.parts: + continue + for part in content.parts: + function_call = getattr(part, "function_call", None) + if function_call is None: + continue + call_id = (getattr(function_call, "id", "") or "").strip() + if not call_id: + continue + out[call_id] = function_call + return out + + def _last_skill_tool_parts( + self, + request: LlmRequest, + tool_calls: dict[str, Any], + ) -> dict[str, Tuple[int, int]]: + out: dict[str, Tuple[int, int]] = {} + for content_idx, content in enumerate(request.contents): + if content.role != "user" or not content.parts: + continue + for part_idx, part in enumerate(content.parts): + function_response = getattr(part, "function_response", None) + if function_response is None: + continue + tool_name = (getattr(function_response, "name", "") or "").strip() + if tool_name not in (SkillToolsNames.LOAD, SkillToolsNames.SELECT_DOCS): + continue + skill_name = self._skill_name_from_tool_response(function_response, tool_calls) + if not skill_name: + continue + out[skill_name] = (content_idx, part_idx) + return out + + def _skill_name_from_tool_response(self, function_response: Any, tool_calls: dict[str, Any]) -> str: + response = getattr(function_response, "response", None) + if isinstance(response, dict): + for key in ("skill", "skill_name", "name"): + value = response.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + call_id = (getattr(function_response, "id", "") or "").strip() + if call_id and call_id in tool_calls: + function_call = tool_calls[call_id] + args = getattr(function_call, "args", None) + for key in ("skill", "skill_name", "name"): + value = self._get_arg_value(args, key) + if value: + return value + + return self._parse_loaded_skill_from_text(self._response_to_text(response)) + + def _get_arg_value(self, args: Any, key: str) -> str: + if isinstance(args, str): + try: + args = json_loads_repair(args) + except json.JSONDecodeError: + return "" + if isinstance(args, dict): + value = args.get(key) + if isinstance(value, str): + return value.strip() + return "" + + def _parse_loaded_skill_from_text(self, content: str) -> str: + text = (content or "").strip() + if not text: + return "" + match = SKILL_LOADED_RE.search(text) + if match: + return match.group(1).strip() + lower = text.lower() + if lower.startswith("loaded:"): + return text[len("loaded:"):].strip() + return "" + + def _response_to_text(self, response: Any) -> str: + if response is None: + return "" + if isinstance(response, str): + return response.strip() + if isinstance(response, dict): + result = response.get("result") + if isinstance(result, str): + return result.strip() + if result is not None: + return str(result).strip() + return json.dumps(response, ensure_ascii=False).strip() + return str(response).strip() + + def _is_loaded_tool_stub(self, tool_output: str, skill_name: str) -> bool: + loaded = self._parse_loaded_skill_from_text(tool_output) + if not loaded: + return False + return loaded.lower() == skill_name.lower() + + def _build_tool_result_content( + self, + ctx: InvocationContext, + repo: BaseSkillRepository, + skill_name: str, + tool_output: str, + ) -> str: + try: + sk = repo.get(skill_name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("skills: get %s failed: %s", skill_name, ex) + return "" + if sk is None: + logger.warning("skills: get %s failed: skill not found", skill_name) + return "" + + parts: list[str] = [] + base = tool_output.strip() + if base and self._is_loaded_tool_stub(base, skill_name): + base = "" + if base: + parts.append(base) + parts.append("\n\n") + + if sk.body.strip(): + parts.append(f"[Loaded] {skill_name}\n\n{sk.body}\n") + + selected_docs = self._get_docs_selection(ctx, skill_name, repo) + parts.append("Docs loaded: ") + if not selected_docs: + parts.append("none\n") + else: + parts.append(", ".join(selected_docs) + "\n") + docs_text = self._build_docs_text(sk, selected_docs) + if docs_text: + parts.append(docs_text) + return "".join(parts).strip() + + def _build_fallback_system_content( + self, + ctx: InvocationContext, + repo: BaseSkillRepository, + loaded: list[str], + materialized: set[str], + ) -> str: + missing = [name for name in loaded if name not in materialized] + if not missing: + return "" + + parts: list[str] = [_SKILLS_LOADED_CONTEXT_HEADER, "\n"] + appended = False + for name in missing: + try: + sk = repo.get(name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("skills: get %s failed: %s", name, ex) + continue + if sk is None: + logger.warning("skills: get %s failed: skill not found", name) + continue + if sk.body.strip(): + parts.append(f"\n[Loaded] {name}\n\n{sk.body}\n") + appended = True + selected_docs = self._get_docs_selection(ctx, name, repo) + parts.append("Docs loaded: ") + if not selected_docs: + parts.append("none\n") + else: + parts.append(", ".join(selected_docs) + "\n") + docs_text = self._build_docs_text(sk, selected_docs) + if docs_text: + parts.append(docs_text) + appended = True + if not appended: + return "" + return "".join(parts).strip() + + def _has_session_summary(self, request: LlmRequest) -> bool: + if request is None or request.config is None: + return False + system_instruction = str(request.config.system_instruction or "") + return _SESSION_SUMMARY_PREFIX in system_instruction + + def _get_docs_selection(self, ctx: InvocationContext, skill_name: str, repo: BaseSkillRepository) -> list[str]: + value = self._read_state(ctx, docs_state_key(ctx, skill_name), default=None) + if not value: + return [] + if isinstance(value, bytes): + try: + value = value.decode("utf-8") + except UnicodeDecodeError: + return [] + if value == "*": + try: + sk = repo.get(skill_name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("skills: get %s failed: %s", skill_name, ex) + return [] + if sk is None: + return [] + return [doc.path for doc in sk.resources] + if not isinstance(value, str): + return [] + try: + arr = json_loads_repair(value) + except json.JSONDecodeError: + return [] + if not isinstance(arr, list): + return [] + return [doc for doc in arr if isinstance(doc, str) and doc.strip()] + + def _build_docs_text(self, sk: Skill, wanted: list[str]) -> str: + if sk is None or not sk.resources: + return "" + want = set(wanted) + parts: list[str] = [] + for resource in sk.resources: + if resource.path not in want or not resource.content: + continue + parts.append(f"\n[Doc] {resource.path}\n\n{resource.content}\n") + return "".join(parts) + + def _maybe_offload_loaded_skills(self, ctx: InvocationContext, loaded: list[str]) -> None: + if get_skill_load_mode(ctx) != SkillLoadModeNames.ONCE or not loaded: + return + for skill_name in loaded: + ctx.actions.state_delta[loaded_state_key(ctx, skill_name)] = None + ctx.actions.state_delta[docs_state_key(ctx, skill_name)] = None + ctx.actions.state_delta[loaded_order_state_key(ctx)] = None + + +def set_skill_tool_result_processor_parameters(agent_context: AgentContext, parameters: dict[str, Any]) -> None: + """Set the parameters of a skill tool result processor by agent context. + + Args: + agent_context: AgentContext object + parameters: Parameters to set + """ + skill_config = get_skill_config(agent_context) + skill_config["skills_tool_result_processor"].update(parameters) + set_skill_config(agent_context, skill_config) + + +def get_skill_tool_result_processor_parameters(agent_context: AgentContext) -> dict[str, Any]: + """Get the parameters of a skill tool result processor. + + Args: + agent_context: AgentContext object + + Returns: + Parameters of the skill tool result processor + """ + skill_config = get_skill_config(agent_context) + return skill_config["skills_tool_result_processor"] diff --git a/trpc_agent_sdk/agents/core/_tools_processor.py b/trpc_agent_sdk/agents/core/_tools_processor.py new file mode 100644 index 000000000..e6d1a2e35 --- /dev/null +++ b/trpc_agent_sdk/agents/core/_tools_processor.py @@ -0,0 +1,775 @@ +# 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. +"""Tools Processor implementation for TRPC Agent framework. + +This module provides the ToolsProcessor class which handles tool invocation +and processing for agents. It uses the unified Event system for communication. + +The ToolsProcessor is simplified to directly use the unified Event class. +""" + +from __future__ import annotations + +import asyncio +import json +import time +from typing import Any +from typing import AsyncGenerator +from typing import List +from typing import Optional +from typing import TypeAlias +from typing import Union + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import EventActions +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.telemetry import report_execute_tool +from trpc_agent_sdk.telemetry import trace_merged_tool_calls +from trpc_agent_sdk.telemetry import trace_tool_call +from trpc_agent_sdk.telemetry import tracer +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.tools import convert_toolunion_to_tool_list +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.utils import json_loads_repair + +# Type aliases for tool definitions +ToolUnion: TypeAlias = Union[BaseTool, BaseToolSet] + + +class ToolsProcessor: + """Tools Processor for handling tool processing and execution. + + This class manages tool declarations for LLM requests and executes + tools when called by the LLM, yielding appropriate events. + """ + + def __init__(self, tools: list[ToolUnion]): + """Initialize ToolsProcessor with a list of tools. + + Args: + tools: List of tools (BaseToolSet) + """ + self.tools = tools + + async def process_llm_request(self, context: InvocationContext, request: LlmRequest) -> None: + """Add tool declarations to the LLM request. + + This method processes the available tools and adds their declarations + to the model request so the LLM can call them. It resolves BaseToolSet + instances by calling their get_tools() method. + + Additionally, this method dynamically detects streaming tools by checking + the is_streaming property on resolved tools, and updates the request's + streaming_tool_names accordingly. This enables proper streaming support + for tools inside ToolSet instances. + + Args: + context: The invocation context + request: The model request to add tools to + + Raises: + Exception: If tool processing fails + """ + if not self.tools: + logger.debug("No tools to add to request") + return + + try: + # Resolve tools first - this is where BaseToolSet.get_tools() is called + resolved_tools = await convert_toolunion_to_tool_list(self.tools, context) + + if resolved_tools: + # Add tools to the request using append_tools directly + for tool in resolved_tools: + await tool.process_request(tool_context=context, llm_request=request) + logger.debug("Added %s tool declarations to request", len(resolved_tools)) + + # Dynamically detect streaming tools and update request + self._update_streaming_tool_names(request, resolved_tools) + else: + logger.warning("No valid tools to add to request") + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing tools: %s", ex, exc_info=True) + raise Exception(f"Tool processing failed: {str(ex)}") from ex + + def _update_streaming_tool_names( + self, + request: LlmRequest, + resolved_tools: List[BaseTool], + ) -> None: + """Update request's streaming_tool_names based on resolved tools. + + This method dynamically detects which tools support streaming by checking + their is_streaming property. Tools that have is_streaming=True will have + their arguments streamed during LLM generation. + + Args: + request: The LLM request to update + resolved_tools: List of resolved BaseTool instances + """ + streaming_names = set() + for tool in resolved_tools: + if getattr(tool, "is_streaming", False): + streaming_names.add(tool.name) + + if streaming_names: + if request.streaming_tool_names is None: + request.streaming_tool_names = set() + request.streaming_tool_names.update(streaming_names) + logger.debug("Detected %d streaming tools: %s", len(streaming_names), streaming_names) + + async def __invoke_tools( + self, + context: InvocationContext, + resolved_tools: List[BaseTool], + tool_call: FunctionCall, + function_response_events: list[Event], + ) -> Event: + # Find the appropriate tool + tool = await self._find_tool(tool_call, resolved_tools) + result_event = None + if not tool: + logger.warning("No tool found for tool call: %s", tool_call.name) + result_event = self._create_error_event( + context, + "tool_not_found", + f"Tool '{tool_call.name}' not found", + tool_call.id, + tool_call.name, + ) + else: + try: + result_event = await self._execute_tool(tool_call, tool, context) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error executing tool %s: %s", tool_call.name, ex, exc_info=True) + result_event = self._create_error_event( + context, + "tool_execution_error", + str(ex), + tool_call.id, + tool_call.name, + ) + function_response_events.append(result_event) + return result_event + + async def execute_tools_async( + self, + tool_calls: List[FunctionCall], + context: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """Execute a list of tool calls and yield events for each result. + + This method: + 1. Uses resolved tools from process_llm_request + 2. Processes tool calls based on parallel_tool_calls setting: + - If parallel_tool_calls=True: Executes all tools in parallel, merges results + - If parallel_tool_calls=False: Executes tools sequentially, yields each immediately + 3. Finds appropriate tool for each call + 4. Executes the tool and collects/yields events + 5. Handles errors gracefully for individual tools + + Args: + tool_calls: List of tool calls from the LLM + context: The invocation context + + Yields: + Event: Events representing tool execution results + - For parallel execution: Single merged event + - For sequential execution: Individual events as they complete + """ + if not tool_calls: + logger.debug("No tool calls to execute") + return + + # Resolve tools for execution + resolved_tools = await convert_toolunion_to_tool_list(self.tools, context) + + logger.debug("Starting execution of %s tool calls", len(tool_calls)) + + # Split the batch by execution model. Progress-streaming tools are + # **never** mixed into the legacy parallel/sequential path: they have + # a different control flow (one tool call -> many events) that does + # not compose with the "1 call -> 1 event, then merge" parallel + # design. The non-streaming bucket is fed through the legacy path + # *verbatim* so that we do not regress any existing behavior. + streaming_calls, non_streaming_calls = self._split_calls_by_streaming(tool_calls, resolved_tools) + + # Capture state before tool execution + state_begin = dict(context.session.state) + + # ---- Phase 1: legacy path for non-streaming tools (unchanged) ---- + if non_streaming_calls: + parallel_tool_calls: bool = getattr(context.agent, "parallel_tool_calls", False) + if parallel_tool_calls: + # Parallel execution: collect all events and merge them + function_response_events: list[Event] = [] + async with asyncio.TaskGroup() as tg: + for tool_call in non_streaming_calls: + tg.create_task(self.__invoke_tools(context, resolved_tools, tool_call, + function_response_events)) + + # Handle merging and tracing based on number of events + if function_response_events: + if len(function_response_events) == 1: + yield function_response_events[0] + else: + merged_event = self._merge_parallel_function_response_events(function_response_events) + state_end = dict(context.session.state) + if merged_event.actions and merged_event.actions.state_delta: + state_end.update(merged_event.actions.state_delta) + with tracer.start_as_current_span( + "execute_tool (merged)", + attributes={"gen_ai.operation.name": "execute_tool"}, + ): + trace_merged_tool_calls( + response_event_id=merged_event.id, + function_response_event=merged_event, + state_begin=state_begin, + state_end=state_end, + ) + yield merged_event + else: + # Sequential execution: yield each event immediately after execution + for tool_call in non_streaming_calls: + function_response_events: list[Event] = [] + result_event = await self.__invoke_tools(context, resolved_tools, tool_call, + function_response_events) + if result_event: + yield result_event + + # ---- Phase 2: uniform streaming path for progress-streaming tools ---- + # Streaming tools are always executed **sequentially among themselves**. + # Interleaving their partials would force the consumer to demux events + # by tool_call_id; we deliberately keep ordering deterministic instead. + # See StreamingProgressTool docstring for the per-tool contract. + for tool_call in streaming_calls: + tool = await self._find_tool(tool_call, resolved_tools) + if tool is None: + yield self._create_error_event( + context, + "tool_not_found", + f"Tool '{tool_call.name}' not found", + tool_call.id, + tool_call.name, + ) + continue + async for ev in self._execute_progress_streaming_tool(tool_call, tool, context): + yield ev + + @staticmethod + def _split_calls_by_streaming( + tool_calls: List[FunctionCall], + resolved_tools: List[BaseTool], + ) -> tuple[List[FunctionCall], List[FunctionCall]]: + """Partition ``tool_calls`` into ``(streaming, non_streaming)`` lists. + + Calls whose target tool cannot be resolved (e.g. typo from the LLM) + are placed in the **non_streaming** bucket so that the legacy path + keeps producing the canonical ``tool_not_found`` error event. + + Relative order within each list is preserved so downstream tracing + stays predictable. + """ + by_name = {t.name: t for t in resolved_tools if isinstance(t, BaseTool)} + streaming: List[FunctionCall] = [] + non_streaming: List[FunctionCall] = [] + for tc in tool_calls: + tool = by_name.get(tc.name) + if tool is not None and tool.is_progress_streaming: + streaming.append(tc) + else: + non_streaming.append(tc) + return streaming, non_streaming + + async def find_tool(self, context: InvocationContext, tool_call: FunctionCall) -> Optional[BaseTool]: + """Find the appropriate tool for a tool call. + + This method first converts the tool union to a tool list, then finds + the appropriate tool for the given tool call. + + Args: + tool_call: The tool call to find a tool for + context: The invocation context + + Returns: + BaseTool: The tool that can handle the call, or None + """ + # Convert tools first + resolved_tools = await convert_toolunion_to_tool_list(self.tools, context) + + # Find the tool using the private method + return await self._find_tool(tool_call, resolved_tools) + + async def _find_tool(self, tool_call: FunctionCall, resolved_tools: List[BaseTool]) -> Optional[BaseTool]: + """Find the appropriate tool for a tool call. + + Args: + tool_call: The tool call to find a tool for + resolved_tools: List of resolved tools to search through + + Returns: + BaseTool: The tool that can handle the call, or None + """ + for tool in resolved_tools: + if isinstance(tool, BaseTool): + if tool.name == tool_call.name: + return tool + return None + + async def _execute_tool(self, tool_call: FunctionCall, tool: BaseTool, context: InvocationContext) -> Event: + """Execute a callable tool. + + Args: + tool_call: The tool call to execute + tool: The tool to execute + context: The invocation context + + Returns: + Event: The result of tool execution + """ + + # Wrap tool execution in telemetry span. + # Pass initial attributes so the Galileo sampler can make a sampling + # decision at span-creation time (before trace_tool_call sets them). + with tracer.start_as_current_span( + f"execute_tool {tool.name}", + attributes={ + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": tool.name, + "gen_ai.tool.description": tool.description or "", + }, + ): + # Capture state before tool execution + state_begin = dict(context.session.state) + + # Parse arguments (FunctionCall uses 'args' field). + # json_repair tolerates malformed JSON from models, but it can also + # silently turn plain text (e.g. "Beijing") into "" or wrap loose + # values into lists. Guard the result so downstream tools always + # receive a dict, falling back to {} when repair cannot recover a + # structured object. + if isinstance(tool_call.args, str): + try: + arguments = json_loads_repair(tool_call.args) + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to repair string tool args for %s: %s", + tool_call.name, + ex, + ) + arguments = {} + if not isinstance(arguments, dict): + logger.warning( + "Discarding non-dict repaired tool args for %s: %r", + tool_call.name, + arguments, + ) + arguments = {} + else: + arguments = tool_call.args or {} + + # Set function call ID for context + context.function_call_id = tool_call.id + + start_time = time.monotonic() + + try: + result = await tool.run_async(tool_context=context, args=arguments) + execution_time = time.monotonic() - start_time + + report_execute_tool( + context, + tool, + duration_s=execution_time, + error_type=None, + ) + + # Build function response + if not isinstance(result, dict): + function_result = {"result": result} + else: + function_result = result + + # Create function response part + part_function_response = Part.from_function_response(name=tool_call.name, response=function_result) + part_function_response.function_response.id = tool_call.id + + # Create content with role='user' + content = Content( + role="user", + parts=[part_function_response], + ) + + # Create event with proper content structure and state delta + event = Event( + invocation_id=context.invocation_id, + author=context.agent.name, + content=content, + custom_metadata={"execution_time": execution_time} if execution_time is not None else {}, + branch=context.branch, + ) + + # Capture state changes from tool execution + if context.state.has_delta(): + event.actions.state_delta.update(context.state._delta) + + # Capture any other actions set by the tool + if context.event_actions.skip_summarization: + event.actions.skip_summarization = True + if context.event_actions.transfer_to_agent: + event.actions.transfer_to_agent = context.event_actions.transfer_to_agent + if context.event_actions.artifact_delta: + event.actions.artifact_delta.update(context.event_actions.artifact_delta) + + # Compute state after tool execution + state_end = dict(context.session.state) + if event.actions and event.actions.state_delta: + state_end.update(event.actions.state_delta) + + # Trace the tool call after building the function response event + trace_tool_call( + tool=tool, + args=arguments, + function_response_event=event, + state_begin=state_begin, + state_end=state_end, + ) + + return event + + except Exception as ex: # pylint: disable=broad-except + report_execute_tool( + context, + tool, + duration_s=time.monotonic() - start_time, + error_type=type(ex).__name__, + ) + + error_event = self._create_error_event(context, "tool_execution_error", str(ex), tool_call.id, + tool_call.name) + + # Compute state after failed tool execution + state_end = dict(context.session.state) + + # Trace the failed tool call + trace_tool_call( + tool=tool, + args=arguments, + function_response_event=error_event, + state_begin=state_begin, + state_end=state_end, + ) + + return error_event + + async def _execute_progress_streaming_tool( + self, + tool_call: FunctionCall, + tool: BaseTool, + context: InvocationContext, + ) -> AsyncGenerator[Event, None]: + """Execute a progress-streaming tool, surfacing every yield as a partial event. + + Contract with :class:`StreamingProgressTool`: + + - Every value yielded by the tool's async generator becomes a + ``partial=True`` Event with ``custom_metadata.tool_progress=True``. + These events are *not* persisted into session history and are *not* + fed back to the LLM as tool responses. + - The **last** yielded value is additionally used to build the final + function_response event (``partial=False``, with a real + ``function_response`` Part) that closes this tool call. + + Args: + tool_call: The LLM-issued FunctionCall to execute. + tool: The resolved StreamingProgressTool instance. + context: The invocation context. + + Yields: + Event: zero or more partial progress events, followed by exactly + one final function_response event (or an error event). + """ + with tracer.start_as_current_span( + f"execute_tool {tool.name} (streaming)", + attributes={ + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": tool.name, + "gen_ai.tool.description": tool.description or "", + }, + ): + state_begin = dict(context.session.state) + + if isinstance(tool_call.args, str): + arguments = json.loads(tool_call.args) + else: + arguments = tool_call.args or {} + + context.function_call_id = tool_call.id + start_time = time.monotonic() + + run_streaming = getattr(tool, "run_streaming", None) + if run_streaming is None: + # Defensive: a tool advertising is_progress_streaming=True + # without run_streaming() is broken. Fall back to non-streaming. + logger.warning( + "Tool %s sets is_progress_streaming=True but exposes no run_streaming(); " + "falling back to non-streaming execution.", + tool.name, + ) + final_event = await self._execute_tool(tool_call, tool, context) + yield final_event + return + + last_value: Any = None + progress_count = 0 + skip_summarization = bool(getattr(tool, "skip_summarization", False)) + + try: + # Drain the streaming generator. Buffer the previously-seen + # value and emit it as a partial event only when a *next* + # value arrives, so that the last value is reserved for the + # final function_response event. + async for value in run_streaming(tool_context=context, args=arguments): + if last_value is not None: + yield self._build_progress_event(context, tool_call, tool, last_value) + progress_count += 1 + last_value = value + + execution_time = time.monotonic() - start_time + report_execute_tool( + context, + tool, + duration_s=execution_time, + error_type=None, + ) + + final_result = last_value if last_value is not None else {} + if not isinstance(final_result, dict): + final_result = {"result": final_result} + + part_function_response = Part.from_function_response(name=tool_call.name, response=final_result) + part_function_response.function_response.id = tool_call.id + + final_event = Event( + invocation_id=context.invocation_id, + author=context.agent.name, + content=Content(role="user", parts=[part_function_response]), + custom_metadata={ + "execution_time": execution_time, + "progress_events": progress_count, + }, + branch=context.branch, + ) + + if context.state.has_delta(): + final_event.actions.state_delta.update(context.state._delta) # pylint: disable=protected-access + if context.event_actions.skip_summarization or skip_summarization: + # Either the tool declared the streamed output as the final + # answer at construction time, or it asked for it via the + # event_actions context bag during execution. + final_event.actions.skip_summarization = True + if context.event_actions.transfer_to_agent: + final_event.actions.transfer_to_agent = context.event_actions.transfer_to_agent + if context.event_actions.artifact_delta: + final_event.actions.artifact_delta.update(context.event_actions.artifact_delta) + + state_end = dict(context.session.state) + if final_event.actions and final_event.actions.state_delta: + state_end.update(final_event.actions.state_delta) + + trace_tool_call( + tool=tool, + args=arguments, + function_response_event=final_event, + state_begin=state_begin, + state_end=state_end, + ) + + yield final_event + + except Exception as ex: # pylint: disable=broad-except + report_execute_tool( + context, + tool, + duration_s=time.monotonic() - start_time, + error_type=type(ex).__name__, + ) + error_event = self._create_error_event( + context, + "tool_execution_error", + str(ex), + tool_call.id, + tool_call.name, + ) + state_end = dict(context.session.state) + trace_tool_call( + tool=tool, + args=arguments, + function_response_event=error_event, + state_begin=state_begin, + state_end=state_end, + ) + logger.error("Error executing streaming tool %s: %s", tool_call.name, ex, exc_info=True) + yield error_event + + @staticmethod + def _build_progress_event( + context: InvocationContext, + tool_call: FunctionCall, + tool: BaseTool, + value: Any, + ) -> Event: + """Wrap a single value yielded by a streaming tool into a partial Event. + + Rules: + - ``str`` → rendered as a text Part directly. + - ``dict`` / anything else → rendered as JSON text Part; the raw + value is also attached under ``custom_metadata['payload']`` so + structured consumers can read it without re-parsing. + - The event is marked ``partial=True`` so session services skip + persisting it and the LLM never sees it as a tool response. + - ``custom_metadata`` carries ``tool_progress=True``, ``tool_name``, + ``tool_call_id`` to make filtering on the consumer side trivial. + """ + if isinstance(value, str): + text = value + payload: Optional[Any] = None + else: + try: + text = json.dumps(value, ensure_ascii=False, default=str) + except (TypeError, ValueError): + text = str(value) + payload = value + + custom_metadata = { + "tool_progress": True, + "tool_name": tool.name, + "tool_call_id": tool_call.id, + } + if payload is not None: + custom_metadata["payload"] = payload + + return Event( + invocation_id=context.invocation_id, + author=context.agent.name, + content=Content(role="model", parts=[Part(text=text)]), + partial=True, + branch=context.branch, + custom_metadata=custom_metadata, + ) + + def _merge_parallel_function_response_events(self, function_response_events: List[Event]) -> Event: + """Merge multiple function response events into a single event. + + This follows the TrpcAgent pattern for merging parallel tool execution results. + + Args: + function_response_events: List of individual tool response events + + Returns: + Event: Merged event containing all tool responses + """ + if not function_response_events: + raise ValueError("No function response events provided.") + + if len(function_response_events) == 1: + return function_response_events[0] + + # Collect all parts from all events + merged_parts = [] + for event in function_response_events: + if event.content and event.content.parts: + merged_parts.extend(event.content.parts) + + # Use the first event as the "base" for common attributes + base_event = function_response_events[0] + + # Merge actions from all events + merged_actions = EventActions() + for event in function_response_events: + if event.actions.skip_summarization: + merged_actions.skip_summarization = True + if event.actions.transfer_to_agent: + merged_actions.transfer_to_agent = event.actions.transfer_to_agent + if event.actions.state_delta: + merged_actions.state_delta.update(event.actions.state_delta) + if event.actions.artifact_delta: + merged_actions.artifact_delta.update(event.actions.artifact_delta) + + # Create the new merged event + merged_event = Event( + invocation_id=Event.new_id(), + author=base_event.author, + content=Content(role="user", parts=merged_parts), + actions=merged_actions, + branch=base_event.branch, + ) + + # Use the base_event timestamp + merged_event.timestamp = base_event.timestamp + return merged_event + + def _create_error_event( + self, + ctx: InvocationContext, + error_code: str, + error_message: str, + tool_call_id: Optional[str] = None, + tool_name: Optional[str] = None, + ) -> Event: + """Create an error event with proper function_response structure. + + This method creates an error event that can be properly converted to OpenAI + tool message format, ensuring error messages are correctly passed to the LLM. + + Args: + ctx: The invocation context containing agent information + error_code: The error code for the event + error_message: The error message for the event + tool_call_id: The ID of the failed tool call (optional) + tool_name: The name of the failed tool (optional) + + Returns: + Event: Error event with proper function_response structure + """ + # Create error response content + error_response = { + "error": error_code, + "message": error_message, + "status": "failed", + } + + # Create function response part + part_function_response = Part.from_function_response( + name=tool_name or "unknown_tool", + response=error_response, + ) + + # Set tool_call_id if provided + + part_function_response.function_response.id = tool_call_id or "unknown_tool_call_id" + + # Create content with role='user' + content = Content( + role="user", + parts=[part_function_response], + ) + + # Create event with proper content structure + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + content=content, + error_code=error_code, + error_message=error_message, + branch=ctx.branch, + ) diff --git a/trpc_agent_sdk/agents/core/_workspace_exec_processor.py b/trpc_agent_sdk/agents/core/_workspace_exec_processor.py new file mode 100644 index 000000000..fc2d4d9ce --- /dev/null +++ b/trpc_agent_sdk/agents/core/_workspace_exec_processor.py @@ -0,0 +1,153 @@ +# 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. +"""Inject workspace_exec guidance into request system instructions. +""" + +from __future__ import annotations + +from typing import Any +from typing import Callable +from typing import Optional + +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.skills import BaseSkillRepository +from trpc_agent_sdk.skills import get_skill_config +from trpc_agent_sdk.skills import set_skill_config + +_WORKSPACE_EXEC_GUIDANCE_HEADER = "Executor workspace guidance:" + + +class WorkspaceExecRequestProcessor: + """Request processor for workspace_exec guidance injection.""" + + def __init__( + self, + *, + session_tools: bool = False, + has_skills_repo: bool = False, + repo_resolver: Optional[Callable[[InvocationContext], Optional[BaseSkillRepository]]] = None, + enabled_resolver: Optional[Callable[[InvocationContext], bool]] = None, + sessions_resolver: Optional[Callable[[InvocationContext], bool]] = None, + ) -> None: + self._session_tools = session_tools + self._static_skills_repo = has_skills_repo + self._repo_resolver = repo_resolver + self._enabled_resolver = enabled_resolver + self._sessions_resolver = sessions_resolver + + async def process_llm_request(self, ctx: InvocationContext, request: LlmRequest) -> None: + """Inject workspace guidance into request.config.system_instruction.""" + if ctx is None or request is None: + return + guidance = self._guidance_text(ctx, request) + if not guidance: + return + + existing = "" + if request.config and request.config.system_instruction: + existing = str(request.config.system_instruction) + if _WORKSPACE_EXEC_GUIDANCE_HEADER in existing: + return + request.append_instructions([guidance]) + + def _guidance_text(self, ctx: InvocationContext, request: LlmRequest) -> str: + if not self._enabled_for_invocation(ctx, request): + return "" + lines: list[str] = [ + _WORKSPACE_EXEC_GUIDANCE_HEADER, + "- Treat workspace_exec as the default general shell runner for shared " + "executor-side work. It runs inside the current executor workspace, not " + "on the agent host; workspace is its scope, not its capability limit.", + "- workspace_exec starts at the workspace root by default. Prefer work/, " + "out/, and runs/ for shared executor-side work, and treat cwd as a " + "workspace-relative path.", + "- Network access depends on the current executor environment. If you " + "need a network command such as curl, use a small bounded command to " + "verify whether that environment allows it.", + "- When a limitation depends on the executor environment and a small " + "bounded command can verify it, verify first before claiming the " + "limitation. This applies to checks such as command availability, file " + "presence, or access to a known URL.", + ] + if self._supports_artifact_save(request): + lines.append("- Use workspace_save_artifact only when you need a stable artifact " + "reference for an already existing file in work/, out/, or runs/. " + "Intermediate files usually stay in the workspace.") + if self._has_skills_repo(ctx): + lines.append("- Paths under skills/ are only useful when some other tool has " + "already placed content there. workspace_exec does not stage skills " + "automatically.") + if self._session_tools_for_invocation(ctx, request): + lines.append("- When workspace_exec starts a command that keeps running or waits " + "for stdin, continue with workspace_write_stdin. When chars is empty, " + "workspace_write_stdin acts like a poll. Use workspace_kill_session " + "to stop a running workspace_exec session.") + lines.append("- Interactive workspace_exec sessions are only guaranteed within the " + "current invocation. Do not assume a later user message can resume " + "the same session.") + return "\n".join(lines).strip() + + def _enabled_for_invocation(self, ctx: InvocationContext, request: LlmRequest) -> bool: + if self._enabled_resolver is not None: + return bool(self._enabled_resolver(ctx)) + return self._has_tool(request, "workspace_exec") + + def _session_tools_for_invocation(self, ctx: InvocationContext, request: LlmRequest) -> bool: + if self._sessions_resolver is not None: + return bool(self._sessions_resolver(ctx)) + if self._session_tools: + return True + return self._has_tool(request, "workspace_write_stdin") and self._has_tool(request, "workspace_kill_session") + + def _has_skills_repo(self, ctx: InvocationContext) -> bool: + if self._repo_resolver is not None: + return self._repo_resolver(ctx) is not None + return self._static_skills_repo + + def _supports_artifact_save(self, request: LlmRequest) -> bool: + return self._has_tool(request, "workspace_save_artifact") + + @staticmethod + def _has_tool(request: LlmRequest, tool_name: str) -> bool: + if request is None or request.config is None or not request.config.tools: + return False + target = (tool_name or "").strip() + if not target: + return False + for tool in request.config.tools: + declarations = getattr(tool, "function_declarations", None) or [] + for declaration in declarations: + name = getattr(declaration, "name", "") + if (name or "").strip() == target: + return True + return False + + +def set_workspace_exec_processor_parameters(agent_context: AgentContext, parameters: dict[str, Any]) -> None: + """Set the parameters of a workspace exec processor by agent context. + + Args: + agent_context: AgentContext object + parameters: Parameters to set + """ + skill_config = get_skill_config(agent_context) + skill_config["workspace_exec_processor"].update(parameters) + set_skill_config(agent_context, skill_config) + + +def get_workspace_exec_processor_parameters(agent_context: AgentContext) -> dict[str, Any]: + """Get the parameters of a workspace exec processor. + + Args: + agent_context: AgentContext object + + Returns: + Parameters of the workspace exec processor + """ + skill_config = get_skill_config(agent_context) + return skill_config["workspace_exec_processor"] diff --git a/trpc_agent_sdk/agents/sub_agent/__init__.py b/trpc_agent_sdk/agents/sub_agent/__init__.py new file mode 100644 index 000000000..c77fe9784 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/__init__.py @@ -0,0 +1,52 @@ +# 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. +# +"""Dynamic sub-agent subsystem. + +Public API: + - ``DynamicSubAgentTool`` — LLM-defined sub-agent: specify ``instruction`` at + call time to create any specialist on the fly. Inherits parent tools. + - ``SpawnSubAgentTool`` — catalog-based dispatch: LLM selects from + pre-registered archetypes (each with locked instruction and tool set). + Supports MD-file authoring via ``agent_paths``. + - ``SubAgentArchetype`` / ``SubAgentRegistry`` — define and register archetypes. + - ``DEFAULT_AGENT`` — neutral built-in archetype, auto-registered by + ``SpawnSubAgentTool``. + - ``GENERAL_PURPOSE_AGENT`` / ``EXPLORE_AGENT`` / ``PLAN_AGENT`` — + opt-in built-in archetypes (researcher / read-only search / read-only + planning). Pass them via ``agents=[...]`` to ``SpawnSubAgentTool``. + - ``load_archetypes_from_dir`` / ``load_archetype_from_file`` — load archetypes + from ``.md`` files on disk (pass ``agent_paths`` to ``SpawnSubAgentTool``). + +This package is **not** re-exported from ``trpc_agent_sdk.agents`` to keep the +default agents import path free of file_tools / web tools dependencies. +""" + +from ._archetype import SubAgentArchetype +from ._defaults import DEFAULT_AGENT +from ._defaults import EXPLORE_AGENT +from ._defaults import GENERAL_PURPOSE_AGENT +from ._defaults import PLAN_AGENT +from ._dynamic_sub_agent_tool import DynamicSubAgentTool +from ._loader import load_archetype_from_file +from ._loader import load_archetypes_from_dir +from ._registry import SubAgentRegistry +from ._spawn_sub_agent_tool import SpawnSubAgentTool +from ._sub_agent_config import SubAgentConfig + +__all__ = [ + "DynamicSubAgentTool", + "SpawnSubAgentTool", + "SubAgentArchetype", + "SubAgentRegistry", + "DEFAULT_AGENT", + "GENERAL_PURPOSE_AGENT", + "EXPLORE_AGENT", + "PLAN_AGENT", + "SubAgentConfig", + "load_archetype_from_file", + "load_archetypes_from_dir", +] diff --git a/trpc_agent_sdk/agents/sub_agent/_archetype.py b/trpc_agent_sdk/agents/sub_agent/_archetype.py new file mode 100644 index 000000000..4fa85c431 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_archetype.py @@ -0,0 +1,89 @@ +# 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. +# +"""Frozen archetype template describing one kind of sub-agent. + +The archetype locks down instruction / tools / model so a call cannot reshape +the sub-agent into something arbitrary. When used with ``SpawnSubAgentTool``, +only ``prompt`` varies at call time; the rest is fixed at registration. When +used with ``DynamicSubAgentTool``, a new archetype is constructed per call. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing import Union + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet + +InstructionProvider = Callable[[InvocationContext], Union[str, Awaitable[str]]] +ToolItem = Union[BaseTool, BaseToolSet, Callable[[], Union[BaseTool, BaseToolSet]]] + +# Permitted name characters: letters, digits, hyphen, underscore. Allows +# both identifier-style ("plan", "ops_audit") and hyphenated style +# ("general-purpose", "code-guide") so users can pick whichever convention +# matches their archetype catalog. +_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + + +@dataclass(frozen=True) +class SubAgentArchetype: + """Template for a kind of sub-agent the parent agent may spawn. + + The two prompt-shaped fields target different audiences and are kept + distinct so each can be written in the right voice: + + - ``description`` is read by the **parent LLM** when it decides which + archetype to spawn. The framework may render it into the spawn tool + description with a trailing ``(Tools: ...)`` suffix for the parent + LLM to read. Phrase it third-person, focused on selection criteria: + "Use it for ... Do NOT use it for ... **IMPORTANT:** ...". + + - ``instruction`` is the **sub-agent's** system prompt. Phrase it + second-person: "You are X. Your role is ... Constraints: ...". + + Other fields: + + - ``tools``: ``None`` = inherit all parent-agent tools at spawn time + (minus spawn tools, which are always stripped). + Otherwise, a tuple of ``BaseTool`` / ``BaseToolSet`` instances OR + zero-arg factory callables (e.g. class references). Factories avoid + import-time side effects and keep tool state per-spawn. + - ``model``: ``None`` = always inherited (resolved via + ``SubAgentConfig.model`` > parent's model at spawn time). + """ + + name: str + description: str + instruction: Union[str, InstructionProvider] + tools: Optional[tuple] = None + model: Any = None + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not _NAME_RE.match(self.name): + raise ValueError(f"SubAgentArchetype.name must match {_NAME_RE.pattern!r}, got {self.name!r}") + if not isinstance(self.description, str) or not self.description.strip(): + raise ValueError("SubAgentArchetype.description must be a non-empty string") + if isinstance(self.instruction, str) and not self.instruction.strip(): + raise ValueError("SubAgentArchetype.instruction must be a non-empty string") + + # Coerce tools to a tuple if a list was passed (frozen dataclass + immutability hint). + if self.tools is not None and not isinstance(self.tools, tuple): + object.__setattr__(self, "tools", tuple(self.tools)) + + def model_or(self, fallback: Any) -> Any: + """Return ``self.model`` if set, otherwise ``fallback``.""" + return self.model if self.model else fallback + + +__all__ = ["SubAgentArchetype", "InstructionProvider", "ToolItem"] diff --git a/trpc_agent_sdk/agents/sub_agent/_constants.py b/trpc_agent_sdk/agents/sub_agent/_constants.py new file mode 100644 index 000000000..5794d6fa3 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_constants.py @@ -0,0 +1,30 @@ +# 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. +# +"""Constants and isolation defaults for the dynamic sub-agent subsystem.""" + +from __future__ import annotations + +SUBAGENT_APP_NAME_SUFFIX = "_trpc_subagent_" +SUBAGENT_USER_ID = "subagent_user" + +# LlmAgent fields that must be flattened on every sub-agent so callbacks / +# transfer hints / output sinks from the parent never leak in. Adding a new +# archetype does not require remembering which fields to wipe — they live here. +ISOLATION_DEFAULTS: dict = { + "sub_agents": [], + "parent_agent": None, + "default_transfer_message": "", + "output_schema": None, + "input_schema": None, + "output_key": None, + "before_agent_callback": None, + "after_agent_callback": None, + "before_model_callback": None, + "after_model_callback": None, + "before_tool_callback": None, + "after_tool_callback": None, +} diff --git a/trpc_agent_sdk/agents/sub_agent/_defaults.py b/trpc_agent_sdk/agents/sub_agent/_defaults.py new file mode 100644 index 000000000..3a2da4061 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_defaults.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 the Apache License Version 2.0. +# +"""Built-in default archetypes shipped with v1. + +Four archetypes cover the common spawn cases out of the box: + +- ``default`` — neutral task executor inheriting all parent tools. + **This is the only archetype auto-registered by ``SpawnSubAgentTool``; + the others must be added explicitly via ``agents=[...]``.** +- ``general-purpose`` — opinionated researcher persona for multi-step code + search and investigation, inheriting all parent tools. +- ``Explore`` — read-only code search and file reading. +- ``Plan`` — read-only software architect for designing implementation plans. + +Tools are stored as **class references** (factories), not instances, so +importing this module does not eagerly construct file_tools / web tools, +and each spawned sub-agent gets its own tool instances. + +When ``tools`` is ``None``, the sub-agent inherits the full tool surface +of its parent agent (minus spawn tools, which are always stripped +to prevent recursive spawning). +""" + +from __future__ import annotations + +from trpc_agent_sdk.tools import GlobTool +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool +from trpc_agent_sdk.tools import WebFetchTool + +from ._archetype import SubAgentArchetype + +# Shared preamble for read-only archetypes (Explore / Plan). Locks the +# sub-agent into a read-only mode at the prompt level (a defense-in-depth +# on top of the narrowed tool surface). +_READ_ONLY_PREAMBLE = """\ +CRITICAL: You are in READ-ONLY mode. You are STRICTLY PROHIBITED from: +- Using Edit, Write, or NotebookEdit tools +- Creating, modifying, or deleting any files +- Using Bash for any write operations (no mkdir, touch, rm, cp, mv, \ +git add, git commit, npm install, pip install, or any file \ +creation/modification) +- Using redirect operators (>, >>) or heredocs in Bash +- Installing packages or dependencies + +You may ONLY use Bash for read-only operations: ls, git status, git log, \ +git diff, find, cat, head, tail. + +Any attempt to modify files will fail and waste your limited turns.""" + +_DEFAULT_INSTRUCTION = """\ +You are a focused sub-agent spawned by a parent agent to handle one \ +specific task. Use the tools available to complete the task described \ +in the prompt. The parent agent only sees your final message — your \ +intermediate tool calls and reasoning are not visible to it — so make \ +the final message self-contained: thorough enough to answer the task, \ +concise enough to be useful.""" + +_GENERAL_PURPOSE_INSTRUCTION = """\ +You are a general-purpose sub-agent. Given the user's message, you should \ +use the tools available to complete the task. Complete the task fully — \ +don't gold-plate, but don't leave it half-done. + +## Strengths + +- Searching code, configs, and patterns across large codebases +- Analyzing multiple files to understand architecture +- Investigating complex questions that need multi-file context +- Multi-step research and implementation tasks + +## Guidelines + +- Search broadly first when the location of relevant code is unknown +- Use Read for specific known paths; use Glob and Grep for discovery +- Start broad, then narrow down to specifics +- Be thorough — check multiple locations and naming conventions +- NEVER create files unless it is absolutely necessary for achieving \ +your goal +- NEVER proactively create documentation files (*.md) or README files \ +unless explicitly requested + +Your response should be a concise report covering what was done and key \ +findings.""" + +_EXPLORE_INSTRUCTION = f"""\ +{_READ_ONLY_PREAMBLE} + +You are a file search specialist. Your role is to rapidly find files, \ +search code, and analyze file contents. + +## Strengths + +- Rapidly finding files using glob patterns +- Searching code with regex patterns +- Reading and analyzing file contents + +## Guidelines + +- Use Glob for broad file pattern matching +- Use Grep for content search with regex +- Use Read when you know a specific file path +- Adapt search approach based on the thoroughness level specified in \ +the prompt (quick / medium / very thorough) +- Make efficient use of tools — issue multiple parallel tool calls \ +for grepping and reading files when possible +- Communicate your findings as a regular message — do NOT attempt to \ +create files +- Complete the search request efficiently and report findings clearly""" + +_PLAN_INSTRUCTION = f"""\ +{_READ_ONLY_PREAMBLE} + +You are a software architect and planning specialist. Your role is to \ +explore the codebase, understand the architecture, and design \ +implementation plans. + +## Process + +1. **Understand Requirements**: Focus on the requirements and the \ +assigned perspective in the prompt. +2. **Explore Thoroughly**: Read provided files, find existing patterns, \ +understand architecture, identify similar features, trace code paths. \ +Use Grep for patterns. +3. **Design Solution**: Create an approach based on the assigned \ +perspective. Consider trade-offs and architectural decisions. Follow \ +existing patterns. +4. **Detail the Plan**: Provide a step-by-step strategy. Identify \ +dependencies and sequencing. Anticipate challenges. + +## Required Output + +End your response with: + +### Critical Files for Implementation +List the 3-5 most important files that will need to be created or \ +modified, with a brief note on the purpose of each change. + +REMINDER: You can ONLY explore and plan. You CANNOT write, edit, or \ +modify any files. You do NOT have access to file editing tools.""" + +DEFAULT_AGENT = SubAgentArchetype( + name="default", + description=("Default sub-agent for implementation and execution tasks. Use " + "for writing code, editing files, running commands, debugging, " + "and other action-oriented work. Inherits the parent agent's full " + "tool surface. If a specialized archetype (Explore, Plan, etc.) " + "fits the task better, prefer it for predictability."), + instruction=_DEFAULT_INSTRUCTION, + tools=None, +) + +GENERAL_PURPOSE_AGENT = SubAgentArchetype( + name="general-purpose", + description=("General-purpose agent for researching complex questions, searching " + "for code, and executing multi-step tasks. When you are searching " + "for a keyword or file and are not confident that you will find the " + "right match in the first few tries use this agent to perform the " + "search for you."), + instruction=_GENERAL_PURPOSE_INSTRUCTION, + tools=None, +) + +EXPLORE_AGENT = SubAgentArchetype( + name="Explore", + description=("Fast agent specialized for exploring codebases. Use this when you " + "need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), " + "search code for keywords (eg. \"API endpoints\"), or answer questions " + "about the codebase (eg. \"how do API endpoints work?\"). When calling " + "this agent, specify the desired thoroughness level: \"quick\" for basic " + "searches, \"medium\" for moderate exploration, or \"very thorough\" for " + "comprehensive analysis across multiple locations and naming conventions."), + instruction=_EXPLORE_INSTRUCTION, + tools=(ReadTool, GlobTool, GrepTool, WebFetchTool), +) + +PLAN_AGENT = SubAgentArchetype( + name="Plan", + description=("Software architect agent for designing implementation plans. Use " + "this when you need to plan the implementation strategy for a task. " + "Returns step-by-step plans, identifies critical files, and considers " + "architectural trade-offs."), + instruction=_PLAN_INSTRUCTION, + tools=(ReadTool, GlobTool, GrepTool), +) + +__all__ = [ + "DEFAULT_AGENT", + "GENERAL_PURPOSE_AGENT", + "EXPLORE_AGENT", + "PLAN_AGENT", +] diff --git a/trpc_agent_sdk/agents/sub_agent/_description.py b/trpc_agent_sdk/agents/sub_agent/_description.py new file mode 100644 index 000000000..834928fa0 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_description.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. +# +"""Render the spawn_subagent tool description from a SubAgentRegistry. + +The rendered description embeds each archetype's name, description text, +and a ``(Tools: ...)`` suffix derived from its tool list — giving the +parent LLM both the selection guidance and the capability boundary in a +single block. +""" + +from __future__ import annotations + +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet + +from ._archetype import SubAgentArchetype +from ._registry import SubAgentRegistry + +_HEADER = """\ +Launch a new sub-agent to handle complex, multi-step tasks. +Each sub-agent type has specific capabilities and tools available to it. + +Available subagent types: +""" + +_FOOTER = """ + +IMPORTANT: The sub-agent cannot spawn further sub-agents. +""" + + +def tool_names_of(archetype: SubAgentArchetype) -> list[str]: + """Extract human-readable tool names from an archetype's tool list. + + - ``BaseTool`` instance: use ``instance.name``. + - ``BaseToolSet`` instance: use the class name (v1 simplification — we do + not expand the toolset to its individual tool names because that would + require an async call). + - Class reference (factory): instantiate to get the real ``name``. + - Other callable: use ``__name__`` if available, else ``repr``. + """ + out: list[str] = [] + if archetype.tools is None: + return ["(all)"] + for t in archetype.tools: + if isinstance(t, BaseTool): + out.append(t.name) + elif isinstance(t, BaseToolSet): + out.append(type(t).__name__) + elif isinstance(t, type) and issubclass(t, BaseTool): + out.append(t().name) + elif callable(t): + out.append(getattr(t, "__name__", repr(t))) + else: + out.append(type(t).__name__) + return out + + +def render_archetype_block(archetype: SubAgentArchetype) -> str: + tool_names = tool_names_of(archetype) + tools_suffix = ", ".join(tool_names) if tool_names else "(none)" + return f"- {archetype.name}: {archetype.description}\n (Tools: {tools_suffix})" + + +def render_tool_description(registry: SubAgentRegistry) -> str: + blocks = "\n".join(render_archetype_block(a) for a in registry.archetypes()) + return _HEADER + blocks + _FOOTER + + +__all__ = ["render_tool_description", "render_archetype_block", "tool_names_of"] 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 new file mode 100644 index 000000000..d1dec7f9d --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py @@ -0,0 +1,229 @@ +# 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. +# +"""DynamicSubAgentTool — on-the-fly sub-agent creation where the LLM defines the role.""" + +from __future__ import annotations + +from typing import Any +from typing import List +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.models import LlmRequest +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._archetype import SubAgentArchetype +from ._runner import run_subagent +from ._sub_agent_config import SubAgentConfig + +_DESCRIPTION = ("Run one short-lived sub-agent for a single focused task and return " + "its result. The sub-agent is created on the fly for this call only " + "and is destroyed afterward. It does NOT transfer control, does NOT " + "run a pre-registered agent by name, and does NOT start a background " + "task. To run several tasks, call this tool multiple times. Its tools " + "stay within a code-defined capability boundary, which by default is " + "derived from what the current agent is already allowed to use (or " + "set explicitly in code), and it cannot select arbitrary agents, " + "models, or executors. IMPORTANT: The sub-agent cannot spawn further " + "sub-agents.") + +_FALLBACK_INSTRUCTION = """\ +You are a focused sub-agent. Use the tools available to complete the task \ +described in the prompt. Be thorough but concise; return a single result \ +that the parent agent can act on directly.""" + + +class DynamicSubAgentTool(BaseTool): + """Run a short-lived sub-agent whose role is defined at call time via + ``instruction``. By default the sub-agent inherits the parent agent's + full tool surface; pass ``tools`` to use a fixed tool set. + + Use this when you cannot predict all the specialist types you'll need + ahead of time — the LLM invents the right role for each task. + + Args: + name: Name of the tool as seen by the LLM. Defaults to + ``"dynamic_subagent"``. + description: Tool description as seen by the LLM. Defaults to + a pre-built description. + tools: Tools available to the sub-agent. ``None`` (default) means + inherit all parent tools. Pass a tuple of ``BaseTool`` instances + or factory callables to use a fixed tool set instead. + expose_tool_selection: When ``True`` (default), the ``tools`` field is + exposed in the schema so the model can restrict which tools the + sub-agent may use. When ``False``, the model cannot narrow the tool + surface. + agent_config: :class:`SubAgentConfig` applied to every spawned + sub-agent. Only non-``None`` fields are forwarded to the + ``LlmAgent`` constructor. + skip_summarization: When ``True``, the parent agent's LLM loop exits + immediately after the sub-agent returns. + filters_name: Filter instance names forwarded to :class:`BaseTool`. + filters: Filter instances forwarded to :class:`BaseTool`. + """ + + def __init__( + self, + name: str = "dynamic_subagent", + description: Optional[str] = None, + tools: Optional[tuple] = None, + expose_tool_selection: bool = True, + agent_config: Optional[SubAgentConfig] = None, + skip_summarization: bool = False, + filters_name: Optional[List[str]] = None, + filters: Optional[List[BaseFilter]] = None, + ) -> None: + self._tools = tools + self._agent_config = agent_config + self._skip_summarization = skip_summarization + self._expose_tool_selection = expose_tool_selection + super().__init__(name=name, description=description or _DESCRIPTION, filters_name=filters_name, filters=filters) + + @override + def _get_declaration(self) -> FunctionDeclaration: + properties: dict = { + "prompt": + Schema( + type=Type.STRING, + description=("The task for the sub-agent. Include all the " + "context it needs to complete the task on its own."), + ), + "instruction": + Schema( + type=Type.STRING, + description=("Optional role, constraints, and execution guidance " + "for this sub-agent invocation. It acts as the " + "sub-agent's system prompt for this run."), + ), + } + + if self._expose_tool_selection: + tools_desc = "Optional exact tool names this sub-agent may use. " \ + "Omit to allow all permitted tools." + if self._tools is not None: + names = _tool_names(self._tools) + if names: + tools_desc += " Available tool names: " + ", ".join(names) + "." + properties["tools"] = Schema( + type=Type.ARRAY, + description=tools_desc, + items=Schema(type=Type.STRING), + ) + + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties=properties, + required=["prompt"], + ), + response=Schema(type=Type.STRING), + ) + + @override + async def process_request( + self, + *, + tool_context: InvocationContext, + llm_request: LlmRequest, + ) -> None: + await super().process_request(tool_context=tool_context, llm_request=llm_request) + include_parent_history = (self._agent_config is not None and self._agent_config.include_parent_history) + if include_parent_history: + instruction = (f"When using `{self.name}`: The sub-agent can see the " + "current conversation's history. Use it when delegated " + "tool work should run in a child invocation while " + "continuing from the current conversation. Describe the " + "task in `prompt`, and optionally set `instruction` to " + "give the sub-agent a role or constraints.") + else: + instruction = (f"When using `{self.name}`: The sub-agent has no memory " + "of this conversation. Use it for self-contained tool " + "work or any task where delegating keeps the parent " + "conversation focused. Put everything it needs in `prompt`. " + "Optionally set `instruction` to give the sub-agent a role " + "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 + + instruction = args.get("instruction") + prompt = args.get("prompt") + + if not isinstance(instruction, str) or not instruction.strip(): + instruction = _FALLBACK_INSTRUCTION + if not isinstance(prompt, str) or not prompt.strip(): + return {"status": "error", "message": "prompt must be a non-empty string"} + + # Resolve tools. + # self._tools: user-configured capability ceiling. + # None → inherit parent tools + # tuple → fixed tool set + # tools_arg (LLM call-time): optional name-based narrowing, only + # honored when expose_tool_selection is True. + # not provided → no filter + # list of names → filter by name (handled in _build_sub_agent) + tool_filter = None + if self._expose_tool_selection: + tools_arg = args.get("tools") + tool_filter = tools_arg if isinstance(tools_arg, list) else None + + synthetic = SubAgentArchetype( + name="dynamic", + description="A focused sub-agent created dynamically for a specific task.", + instruction=instruction, + tools=self._tools, # None=inherit, tuple=fixed set + ) + return await run_subagent( + parent_ctx=tool_context, + archetype=synthetic, + prompt=prompt, + agent_config=self._agent_config, + tool_filter=tool_filter, + ) + + +def _tool_names(tools: tuple) -> list[str]: + """Extract declaration names from a tuple of tool items. + + Handles ``BaseTool`` instances, ``BaseToolSet`` instances, and factory + callables (e.g. class references). + """ + from trpc_agent_sdk.tools import BaseToolSet + + names: list[str] = [] + for t in tools: + if isinstance(t, BaseTool): + name = getattr(t, 'name', None) + elif isinstance(t, BaseToolSet): + name = type(t).__name__ + elif isinstance(t, type) and issubclass(t, BaseTool): + name = t().name + elif callable(t): + name = getattr(t, "__name__", None) + else: + continue + if name: + names.append(name) + return names + + +__all__ = ["DynamicSubAgentTool"] diff --git a/trpc_agent_sdk/agents/sub_agent/_loader.py b/trpc_agent_sdk/agents/sub_agent/_loader.py new file mode 100644 index 000000000..1f88d5ce3 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_loader.py @@ -0,0 +1,229 @@ +# 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. +# +"""Load SubAgentArchetype definitions from Markdown files. + +File format (YAML frontmatter + Markdown body):: + + --- + name: my-researcher + description: Use this agent for deep research tasks. + tools: # optional; defaults to inheriting all parent tools + - Read + - websearch + --- + + You are a research specialist. Your task is to … + (this section becomes the sub-agent's system instruction) + +Required frontmatter fields: ``name``, ``description``. +Optional: ``tools``. +Body (instruction) must be non-empty after stripping whitespace. + +If ``tools`` is omitted, the archetype inherits the full tool surface of +the parent agent at spawn time (minus spawn tools, which are always +stripped to prevent recursive spawning). + +When specified, tools are referenced by their actual tool ``name`` +(e.g. ``Read``, ``Bash``, ``websearch``). Any name not in the whitelist +raises ``ValueError`` at load time (fail-fast). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any +from typing import List + +import yaml + +from ._archetype import SubAgentArchetype + +# --------------------------------------------------------------------------- +# Built-in tool whitelist — maps actual tool name → factory class reference. +# Populated lazily to avoid import-time side effects; see _tool_whitelist(). +# --------------------------------------------------------------------------- + +# Maps tool.name → class reference, e.g. "Read" -> ReadTool, "Bash" -> BashTool +_WHITELIST_NAMES = { + "Bash", + "Edit", + "Glob", + "Grep", + "Read", + "webfetch", + "websearch", + "Write", +} + + +def _tool_whitelist() -> dict[str, Any]: + from trpc_agent_sdk.tools import BashTool + from trpc_agent_sdk.tools import EditTool + from trpc_agent_sdk.tools import GlobTool + from trpc_agent_sdk.tools import GrepTool + from trpc_agent_sdk.tools import ReadTool + from trpc_agent_sdk.tools import WebFetchTool + from trpc_agent_sdk.tools import WebSearchTool + from trpc_agent_sdk.tools import WriteTool + + return { + "Bash": BashTool, + "Edit": EditTool, + "Glob": GlobTool, + "Grep": GrepTool, + "Read": ReadTool, + "webfetch": WebFetchTool, + "websearch": WebSearchTool, + "Write": WriteTool, + } + + +# --------------------------------------------------------------------------- +# Frontmatter parsing +# --------------------------------------------------------------------------- + +_FM_DELIMITER = "---" + + +def _split_frontmatter(text: str) -> tuple[str, str]: + """Return ``(frontmatter_yaml, body)`` or ``("", text)`` if no frontmatter.""" + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != _FM_DELIMITER: + return "", text + + end = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == _FM_DELIMITER: + end = i + break + + if end is None: + # Unclosed frontmatter — treat whole file as body (no frontmatter). + return "", text + + fm_yaml = "".join(lines[1:end]) + body = "".join(lines[end + 1:]) + return fm_yaml, body + + +# --------------------------------------------------------------------------- +# Single-file loader +# --------------------------------------------------------------------------- + + +def load_archetype_from_file(path: Path, tool_mapping: dict[str, Any] | None = None) -> SubAgentArchetype: + """Parse a single ``.md`` file and return a ``SubAgentArchetype``. + + Args: + path: Path to the ``.md`` file. + tool_mapping: Optional name-to-class mapping for resolving custom + tool names referenced in the frontmatter. Merged with the + built-in whitelist; custom entries take precedence. + + Raises ``ValueError`` with a path-prefixed message on any parse or + validation error so callers get precise diagnostics. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"{path}: cannot read file: {exc}") from exc + + fm_yaml, body = _split_frontmatter(text) + + if not fm_yaml.strip(): + raise ValueError(f"{path}: missing YAML frontmatter. " + "File must start with '---' followed by at least 'name' and 'description'.") + + try: + fm: dict = yaml.safe_load(fm_yaml) or {} + except yaml.YAMLError as exc: + raise ValueError(f"{path}: invalid YAML frontmatter: {exc}") from exc + + if not isinstance(fm, dict): + raise ValueError(f"{path}: frontmatter must be a YAML mapping, got {type(fm).__name__}") + + # --- required fields --- + name = fm.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{path}: frontmatter 'name' must be a non-empty string") + + description = fm.get("description") + if not isinstance(description, str) or not description.strip(): + raise ValueError(f"{path}: frontmatter 'description' must be a non-empty string") + + # --- instruction (body) --- + instruction = body.strip() + if not instruction: + raise ValueError(f"{path}: instruction body (text after the closing '---') must be non-empty") + + # --- optional: tools --- + raw_tools = fm.get("tools") + if raw_tools is None: + tools = None + else: + if not isinstance(raw_tools, list): + raise ValueError(f"{path}: frontmatter 'tools' must be a YAML list, got {type(raw_tools).__name__}") + whitelist = _tool_whitelist() + if tool_mapping: + whitelist = {**whitelist, **tool_mapping} + resolved = [] + for item in raw_tools: + if not isinstance(item, str): + raise ValueError(f"{path}: each tool entry must be a string, got {type(item).__name__!r}") + if item not in whitelist: + allowed = sorted(set(_WHITELIST_NAMES) | set(tool_mapping or ())) + raise ValueError(f"{path}: unknown tool {item!r}. " + f"Allowed: {allowed}") + resolved.append(whitelist[item]) + tools = tuple(resolved) + + return SubAgentArchetype( + name=name.strip(), + description=description.strip(), + instruction=instruction, + tools=tools, + ) + + +# --------------------------------------------------------------------------- +# Directory loader +# --------------------------------------------------------------------------- + + +def load_archetypes_from_dir(directory: os.PathLike, + tool_mapping: dict[str, Any] | None = None) -> List[SubAgentArchetype]: + """Load all ``*.md`` files in *directory* as ``SubAgentArchetype`` objects. + + Files are sorted alphabetically so the registration order is deterministic. + Raises ``ValueError`` if *directory* does not exist or if any file fails + to parse (fail-fast; all errors are reported with full file paths). + """ + dirpath = Path(directory) + if not dirpath.exists(): + raise ValueError(f"agents_path does not exist: {dirpath}") + if not dirpath.is_dir(): + raise ValueError(f"agents_path is not a directory: {dirpath}") + + md_files = sorted(dirpath.glob("*.md")) + + archetypes = [] + errors: list[str] = [] + for md_file in md_files: + try: + archetypes.append(load_archetype_from_file(md_file, tool_mapping=tool_mapping)) + except ValueError as exc: + errors.append(str(exc)) + + if errors: + joined = "\n ".join(errors) + raise ValueError(f"Failed to load archetypes from {dirpath}:\n {joined}") + + return archetypes + + +__all__ = ["load_archetype_from_file", "load_archetypes_from_dir"] diff --git a/trpc_agent_sdk/agents/sub_agent/_registry.py b/trpc_agent_sdk/agents/sub_agent/_registry.py new file mode 100644 index 000000000..4aff0f941 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_registry.py @@ -0,0 +1,55 @@ +# 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. +# +"""Insertion-ordered registry of sub-agent archetypes.""" + +from __future__ import annotations + +from typing import Iterator + +from ._archetype import SubAgentArchetype + + +class SubAgentRegistry: + """A catalog of archetypes the SpawnSubAgentTool may instantiate. + + The registry preserves insertion order so the rendered tool description + is deterministic. Duplicate names are rejected; lookups for unknown + names raise ``KeyError``. + """ + + def __init__(self) -> None: + self._items: dict[str, SubAgentArchetype] = {} + + def register(self, archetype: SubAgentArchetype) -> None: + """Register a new archetype. Raises ``ValueError`` on name collision.""" + if archetype.name in self._items: + raise ValueError(f"archetype name {archetype.name!r} already registered") + self._items[archetype.name] = archetype + + def get(self, name: str) -> SubAgentArchetype: + """Return the archetype with the given name. Raises ``KeyError`` if absent.""" + if name not in self._items: + raise KeyError(f"archetype {name!r} not found in registry") + return self._items[name] + + def names(self) -> list[str]: + return list(self._items.keys()) + + def archetypes(self) -> list[SubAgentArchetype]: + return list(self._items.values()) + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and name in self._items + + def __iter__(self) -> Iterator[SubAgentArchetype]: + return iter(self._items.values()) + + def __len__(self) -> int: + return len(self._items) + + +__all__ = ["SubAgentRegistry"] diff --git a/trpc_agent_sdk/agents/sub_agent/_runner.py b/trpc_agent_sdk/agents/sub_agent/_runner.py new file mode 100644 index 000000000..79302901b --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_runner.py @@ -0,0 +1,342 @@ +# 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. +# +"""Sub-agent construction and execution via a nested Runner. + +This mirrors the pattern used by ``trpc_agent_sdk.tools._agent_tool.AgentTool`` +but applies stricter isolation: the parent's session/state/memory/callbacks are +**not** shared into the sub-agent. Artifacts are forwarded back to the parent +context so files produced by the sub-agent remain accessible to the orchestrator. + +Sub-agent metadata (``_is_subagent`` / ``_subagent_type`` / ``_parent_invocation_id``) +is threaded into the spawned run via ``Runner.run_async(..., agent_context=...)``. +``AgentContext.with_metadata`` is the existing mechanism — no new fields added. +""" + +from __future__ import annotations + +from typing import Any +from typing import Optional +from typing import Union + +from trpc_agent_sdk.abc import ArtifactId +from trpc_agent_sdk.agents._llm_agent import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from ._archetype import SubAgentArchetype +from ._constants import ISOLATION_DEFAULTS +from ._constants import SUBAGENT_APP_NAME_SUFFIX +from ._constants import SUBAGENT_USER_ID + + +class _BorrowedToolSet(BaseToolSet): + """Wraps a parent-owned ToolSet for use in a sub-agent. + + Proxies get_tools() to the inner instance but makes close() a no-op, + preventing sub_runner.close() from tearing down the parent's connections. + """ + + def __init__(self, inner: BaseToolSet) -> None: + super().__init__() + self._inner = inner + + async def get_tools(self, invocation_context=None): + return await self._inner.get_tools(invocation_context) + + async def close(self) -> None: + pass # borrowed — lifecycle owned by parent runner + + +def _materialize_tools(tools: tuple) -> list: + """Convert archetype tool items (instances or factories) to instances.""" + out: list = [] + for t in tools: + if isinstance(t, (BaseTool, BaseToolSet)): + out.append(t) + elif callable(t): + out.append(t()) + else: + raise TypeError(f"archetype tool item {t!r} is neither a BaseTool/BaseToolSet " + f"instance nor a zero-arg factory") + return out + + +def _resolve_model(agent_config, parent_ctx: InvocationContext) -> Any: + if agent_config is not None and agent_config.model: + return agent_config.model + parent_model = getattr(parent_ctx.agent, "model", None) + if parent_model: + return parent_model + raise ValueError("sub-agent: cannot resolve model. Provide " + "SubAgentConfig.model, or set a model on the parent agent.") + + +def _resolve_skill_repository(tools: list) -> Any: + """Find a SkillToolSet in *tools* and return its repository, else None. + + Couples skill_repository to the presence of SkillToolSet so that skill + capability and skill metadata travel together. Looks through + _BorrowedToolSet wrappers so inherited skill toolsets work too. + """ + from trpc_agent_sdk.skills import SkillToolSet + for t in tools: + inner = t._inner if isinstance(t, _BorrowedToolSet) else t + if isinstance(inner, SkillToolSet): + return inner.repository + return None + + +def _is_user_text_event(event: Any) -> bool: + """Return True if *event* is a user message containing plain text.""" + if getattr(event, "author", None) != "user": + return False + parts = getattr(event.content, "parts", None) if getattr(event, "content", None) else None + if not parts: + return False + return any(getattr(p, "text", None) for p in parts) + + +def _event_is_model_visible(event: Any) -> bool: + """Return True if *event* is model-visible. + + ``Event.is_model_visible`` is a method, so it must be called. + """ + vis = getattr(event, "is_model_visible", None) + if callable(vis): + return vis() + return bool(vis) if vis is not None else True + + +def _collect_parent_events(parent_ctx: InvocationContext, max_parent_history_turns: Optional[int]) -> list: + """Collect model-visible parent events, limited to the last *max_parent_history_turns* turns. + + A turn starts with a user text message. If *max_parent_history_turns* is ``None``, + all available events are returned. + """ + events = getattr(parent_ctx.session, "events", None) or [] + if not events: + return [] + + # Keep only model-visible events that have content. + visible = [e for e in events if _event_is_model_visible(e) and getattr(e, "content", None)] + + if max_parent_history_turns is None: + return visible + if not max_parent_history_turns: + return [] + + # Count turns backward from the end. + turn_start_idx = 0 + turn_count = 0 + for i in range(len(visible) - 1, -1, -1): + if _is_user_text_event(visible[i]): + turn_count += 1 + if turn_count >= max_parent_history_turns: + turn_start_idx = i + break + + return visible[turn_start_idx:] + + +def _build_sub_agent( + archetype: SubAgentArchetype, + parent_ctx: InvocationContext, + agent_config=None, + tool_filter: Optional[list] = None, +) -> LlmAgent: + if archetype.tools is None: + # Inherit the full tool surface of the parent agent. BaseTool instances + # are shared directly (stateless). BaseToolSet instances are wrapped in + # _BorrowedToolSet so sub_runner.close() cannot tear down the parent's + # connections (e.g. MCPToolset sessions). + parent_tools = getattr(parent_ctx.agent, 'tools', []) or [] + tools = [_BorrowedToolSet(t) if isinstance(t, BaseToolSet) else t for t in parent_tools] + else: + tools = _materialize_tools(archetype.tools) + + # Always strip SpawnSubAgentTool and DynamicSubAgentTool from the sub-agent's + # tool surface, preventing sub-agents from spawning further sub-agents + # (1-level cap). + tools = [t for t in tools if type(t).__name__ not in ("DynamicSubAgentTool", "SpawnSubAgentTool")] + + # Apply optional name-based tool filter from the LLM. BaseToolSet wrappers + # are always kept (they are infrastructure, not selectable by name). + if tool_filter is not None: + name_map = {} + base_sets: list = [] + for t in tools: + if isinstance(t, _BorrowedToolSet): + base_sets.append(t) + continue + name = getattr(t, 'name', None) + if name: + name_map[name] = t + filtered = [name_map[n] for n in tool_filter if n in name_map] + tools = filtered + base_sets + + # archetype.name may contain hyphens (e.g. "general-purpose"); LlmAgent.name + # must be a Python identifier, so normalize hyphens to underscores. + safe_name = archetype.name.replace("-", "_") + + parent = parent_ctx.agent + + llm_kwargs: dict = {} + llm_kwargs["name"] = f"subagent_{safe_name}" + llm_kwargs["description"] = archetype.description + llm_kwargs["instruction"] = archetype.instruction + llm_kwargs["model"] = _resolve_model(agent_config, parent_ctx) + llm_kwargs["tools"] = tools + + if agent_config is not None and agent_config.generate_content_config is not None: + llm_kwargs["generate_content_config"] = agent_config.generate_content_config + else: + llm_kwargs["generate_content_config"] = getattr(parent, "generate_content_config", None) + + if agent_config is not None and agent_config.parallel_tool_calls is not None: + llm_kwargs["parallel_tool_calls"] = agent_config.parallel_tool_calls + else: + llm_kwargs["parallel_tool_calls"] = getattr(parent, "parallel_tool_calls", False) + + # Detect SkillToolSet in tools to populate skill_repository. + llm_kwargs["skill_repository"] = _resolve_skill_repository(tools) + + llm_kwargs.update(ISOLATION_DEFAULTS) + return LlmAgent(**llm_kwargs) + + +async def _forward_artifacts(sub_runner, sub_session, parent_ctx: InvocationContext) -> None: + """Copy artifacts produced by the sub-agent into the parent context. + + Mirrors the artifact forwarding done by AgentTool — without it, files + written by the sub-agent become unreachable once the sub-runner closes. + """ + if not sub_runner.artifact_service: + return + artifact_id = ArtifactId( + app_name=sub_session.app_name, + user_id=sub_session.user_id, + session_id=sub_session.id, + ) + keys = await sub_runner.artifact_service.list_artifact_keys(artifact_id=artifact_id) + for filename in keys: + artifact = await sub_runner.artifact_service.load_artifact(artifact_id=ArtifactId( + app_name=sub_session.app_name, + user_id=sub_session.user_id, + session_id=sub_session.id, + filename=filename, + ), ) + if artifact: + await parent_ctx.save_artifact(filename=filename, artifact=artifact) + + +def _extract_final_text(last_event) -> str: + if not last_event or not last_event.content or not last_event.content.parts: + return "" + return "\n".join(p.text for p in last_event.content.parts if getattr(p, "text", None)) + + +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. + + 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. + """ + # Imported lazily to mirror AgentTool and avoid a circular import at module load. + from trpc_agent_sdk.runners import Runner + + try: + 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)} + + parent_app_name = getattr(parent_ctx.session, "app_name", "trpc_app") + sub_app_name = f"{parent_app_name}{SUBAGENT_APP_NAME_SUFFIX}{archetype.name}" + + sub_runner = Runner( + app_name=sub_app_name, + agent=sub_agent, + session_service=InMemorySessionService(), + memory_service=InMemoryMemoryService(), + artifact_service=parent_ctx.artifact_service, + enable_post_turn_processing=False, + ) + + last_event = None + max_turns_reached = False + try: + sub_session = await sub_runner.session_service.create_session( + app_name=sub_app_name, + user_id=SUBAGENT_USER_ID, + state={}, + ) + + # Inject parent conversation history if configured. + if agent_config is not None and agent_config.include_parent_history: + parent_events = _collect_parent_events(parent_ctx, agent_config.max_parent_history_turns) + for event in parent_events: + await sub_runner.session_service.append_event(sub_session, event) + + max_turns = agent_config.max_turns if agent_config is not None else None + turn_count = 0 + + content = Content(role="user", parts=[Part.from_text(text=prompt)]) + async for event in sub_runner.run_async( + user_id=sub_session.user_id, + session_id=sub_session.id, + new_message=content, + ): + last_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(): + if event.content.role == "model": + turn_count += 1 + if max_turns is not None and turn_count >= max_turns: + max_turns_reached = True + break + # Strict isolation: do NOT propagate event.actions.state_delta + # to the parent context (this is the deliberate divergence from + # AgentTool's behavior). + + await _forward_artifacts(sub_runner, sub_session, parent_ctx) + except RunCancelledException: + return "[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)} + 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 + + +__all__ = ["run_subagent"] 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 new file mode 100644 index 000000000..b49f2e3c3 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py @@ -0,0 +1,208 @@ +# 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. +# +"""SpawnSubAgentTool — spawn sub-agents from pre-registered archetype templates.""" + +from __future__ import annotations + +import os +from typing import Any +from typing import List +from typing import Optional +from typing import Union +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._archetype import SubAgentArchetype +from ._defaults import DEFAULT_AGENT +from ._description import render_tool_description +from ._loader import load_archetypes_from_dir +from ._registry import SubAgentRegistry +from ._runner import run_subagent +from ._sub_agent_config import SubAgentConfig + + +class SpawnSubAgentTool(BaseTool): + """Tool for spawning pre-defined archetype-based sub-agents. + + Each archetype has a locked instruction, tool set, and model — the LLM + selects which archetype to use and writes the task prompt, but cannot + redefine the archetype's role or capabilities at call time. + + Pre-built archetypes (``DEFAULT_AGENT``, ``GENERAL_PURPOSE_AGENT``, + ``EXPLORE_AGENT``, ``PLAN_AGENT``) are exported from the package for + manual composition; only ``default`` is auto-registered. + + Archetypes can be loaded from ``*.md`` files:: + + --- + name: my-researcher + description: Use this agent for deep research tasks. + tools: # optional; if omitted, sub-agent inherits parent tools + - Read + - websearch + --- + + You are a research specialist. Your task is to … + + Args: + agents: Additional archetypes to register (or override ``default`` + with a custom version). + agent_paths: One or more directories of ``*.md`` files to load + archetypes from disk. + tool_mapping: Optional name-to-class mapping for resolving custom + tool names in MD frontmatter (``agent_paths``). Merged with the + built-in whitelist; custom entries take precedence. + with_default: Whether to register the built-in ``default`` + archetype as a universal fallback. Defaults to ``True``; + set to ``False`` when you want full control over the archetype catalog. + agent_config: :class:`SubAgentConfig` applied to every spawned + sub-agent. Only non-``None`` fields are forwarded to the + ``LlmAgent`` constructor. + skip_summarization: When ``True``, the parent agent's LLM loop exits + immediately after the sub-agent returns, saving the token cost of + a final summarization turn. + filters_name: Filter instance names forwarded to :class:`BaseTool`. + filters: Filter instances forwarded to :class:`BaseTool`. + """ + + def __init__( + self, + agents: Optional[List[SubAgentArchetype]] = None, + agent_paths: Optional[List[Union[str, os.PathLike]]] = None, + tool_mapping: Optional[dict[str, Any]] = None, + with_default: bool = True, + agent_config: Optional[SubAgentConfig] = None, + skip_summarization: bool = False, + filters_name: Optional[List[str]] = None, + filters: Optional[List[BaseFilter]] = None, + ) -> None: + registry = SubAgentRegistry() + if with_default: + registry.register(DEFAULT_AGENT) + for archetype in agents or []: + if archetype.name in registry: + raise ValueError(f"archetype name {archetype.name!r} collides with an " + "already-registered archetype") + registry.register(archetype) + if agent_paths is not None: + for path in agent_paths: + for archetype in load_archetypes_from_dir(path, tool_mapping=tool_mapping): + if archetype.name in registry: + raise ValueError(f"archetype name {archetype.name!r} from {path!r} " + "collides with an already-registered archetype") + registry.register(archetype) + + self._registry = registry + self._skip_summarization = skip_summarization + self._agent_config = agent_config + rendered = render_tool_description(registry) + super().__init__(name="spawn_subagent", description=rendered, filters_name=filters_name, filters=filters) + + @property + def registry(self) -> SubAgentRegistry: + return self._registry + + @override + def _get_declaration(self) -> FunctionDeclaration: + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties={ + "subagent_type": + Schema( + type=Type.STRING, + enum=self._registry.names(), + description=("The type of specialized agent to use for this task. " + "See tool description for capabilities of each."), + ), + "prompt": + Schema( + type=Type.STRING, + description=("The task for the sub-agent. Include all the " + "context it needs to complete the task on its own."), + ), + "description": + Schema( + type=Type.STRING, + description=("Short label (3-7 words) of what this sub-agent will do."), + ), + }, + required=["prompt", "description"], + ), + response=Schema(type=Type.STRING), + ) + + @override + async def process_request( + self, + *, + tool_context: InvocationContext, + llm_request: LlmRequest, + ) -> None: + await super().process_request(tool_context=tool_context, llm_request=llm_request) + include_parent_history = (self._agent_config is not None and self._agent_config.include_parent_history) + if include_parent_history: + instruction = ("When using `spawn_subagent`: The sub-agent can see the " + "current conversation's history. Use it when delegated " + "tool work should run in a child invocation while " + "continuing from the current conversation. Still describe " + "the task in `prompt`.") + else: + instruction = ("When using `spawn_subagent`: The sub-agent has no memory " + "of this conversation. Use it for self-contained tool " + "work, multiple independent subtasks, or any task where " + "delegating keeps the parent conversation focused instead " + "of filling it with tool details and intermediate steps. " + "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 + + subagent_type = args.get("subagent_type") + prompt = args.get("prompt") + + # Resolve subagent_type, falling back to default if missing or unknown. + if isinstance(subagent_type, str) and subagent_type in self._registry: + resolved_type = subagent_type + elif "default" in self._registry: + resolved_type = "default" + else: + return { + "status": "error", + "message": (f"unknown subagent_type: {subagent_type!r}. " + f"Available: {self._registry.names()}"), + } + 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 await run_subagent( + parent_ctx=tool_context, + archetype=archetype, + prompt=prompt, + agent_config=self._agent_config, + ) + + +__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 new file mode 100644 index 000000000..075c98716 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.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. +# +"""Construction-time defaults applied to every spawned sub-agent.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.types import GenerateContentConfig + + +@dataclass(frozen=True) +class SubAgentConfig: + """Configuration for every spawned sub-agent. + + ``None`` means "inherit from the parent agent or use the default". + """ + + model: Optional[LLMModel] = None + """Model the sub-agent uses. ``None`` inherits the parent's model.""" + + generate_content_config: Optional[GenerateContentConfig] = None + """Generation configuration (temperature, top_p, etc.). ``None`` inherits from parent.""" + + parallel_tool_calls: Optional[bool] = None + """Whether the sub-agent may issue parallel tool calls. ``None`` inherits from parent.""" + + include_parent_history: bool = False + """Whether to inject parent conversation history into the sub-agent's session.""" + + max_parent_history_turns: Optional[int] = None + """Max parent turns to inject. ``None`` = unlimited. + Only used when ``include_parent_history`` is ``True``.""" + + max_turns: Optional[int] = None + """Max LLM calls the sub-agent may make. ``None`` = unlimited. + Each LLM request counts as one turn, including those with tool calls.""" + + +__all__ = ["SubAgentConfig"] diff --git a/trpc_agent_sdk/agents/utils/__init__.py b/trpc_agent_sdk/agents/utils/__init__.py new file mode 100644 index 000000000..fd8d2e135 --- /dev/null +++ b/trpc_agent_sdk/agents/utils/__init__.py @@ -0,0 +1,44 @@ +# 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. +""" +TRPC Agent Context Utilities Module. +""" + +from ._langgraph import AGENT_CTX_KEY +from ._langgraph import CHUNK_KEY +from ._langgraph import LANGGRAPH_KEY +from ._langgraph import STREAM_MODE_KEY +from ._langgraph import TRPC_AGENT_KEY +from ._langgraph import get_agent_context +from ._langgraph import get_langgraph_payload +from ._langgraph import langgraph_llm_node +from ._langgraph import langgraph_tool_node +from ._langgraph_event_writer import LANGGRAPH_EVENT_TYPE +from ._langgraph_event_writer import LangGraphEventType +from ._langgraph_event_writer import LangGraphEventWriter +from ._langgraph_event_writer import TRPC_EVENT_MARKER +from ._langgraph_event_writer import extract_trpc_event +from ._langgraph_event_writer import get_event_type +from ._langgraph_event_writer import is_trpc_event_chunk + +__all__ = [ + "AGENT_CTX_KEY", + "CHUNK_KEY", + "LANGGRAPH_KEY", + "STREAM_MODE_KEY", + "TRPC_AGENT_KEY", + "get_agent_context", + "get_langgraph_payload", + "langgraph_llm_node", + "langgraph_tool_node", + "LANGGRAPH_EVENT_TYPE", + "LangGraphEventType", + "LangGraphEventWriter", + "TRPC_EVENT_MARKER", + "extract_trpc_event", + "get_event_type", + "is_trpc_event_chunk", +] diff --git a/trpc_agent_sdk/agents/utils/_langgraph.py b/trpc_agent_sdk/agents/utils/_langgraph.py new file mode 100644 index 000000000..19effb728 --- /dev/null +++ b/trpc_agent_sdk/agents/utils/_langgraph.py @@ -0,0 +1,354 @@ +# 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. +# +"""LangGraph utilities for TRPC Agent framework.""" + +import functools +import inspect +from typing import Any +from typing import Dict +from typing import Optional + +from google.genai import types +from langchain_core.messages import AIMessage +from langchain_core.runnables.config import RunnableConfig + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +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.telemetry import trace_call_llm +from trpc_agent_sdk.telemetry import trace_tool_call +from trpc_agent_sdk.telemetry import tracer +from trpc_agent_sdk.tools import BaseTool + +# Private string literal constants +TRPC_AGENT_KEY = "__trpc_agent__" +AGENT_CTX_KEY = "ctx" +LANGGRAPH_KEY = "langgraph" +STREAM_MODE_KEY = "stream_mode" +CHUNK_KEY = "chunk" + + +def get_agent_context(config: Dict[str, Any]) -> InvocationContext: + """Extract InvocationContext from runnable config or similar structure. + + Args: + config: Dictionary that may contain __trpc_agent__ key with context + + Returns: + InvocationContext from the config + + Raises: + ValueError: If InvocationContext is not found in the config + """ + if isinstance(config, dict) and "configurable" in config and TRPC_AGENT_KEY in config["configurable"]: + trpc_agent_data = config["configurable"][TRPC_AGENT_KEY] + if isinstance(trpc_agent_data, dict) and AGENT_CTX_KEY in trpc_agent_data: + return trpc_agent_data[AGENT_CTX_KEY] + + raise ValueError(f"InvocationContext not found in config. Make sure the config contains " + f"'{TRPC_AGENT_KEY}' with '{AGENT_CTX_KEY}' key.") + + +def get_langgraph_payload(event: Event) -> Optional[Dict[str, Any]]: + """Extract stream mode and chunk from a LangGraph event. + + Args: + event: The event to extract information from + + Returns: + Dictionary containing langgraph data with stream_mode and chunk, + or None if no LangGraph stream data is found. + """ + if event.custom_metadata and LANGGRAPH_KEY in event.custom_metadata: + return event.custom_metadata[LANGGRAPH_KEY] + + return None + + +def _ensure_config_parameter(func): + """Private helper to ensure a function has a config parameter. + + If the function doesn't have a config parameter, this creates a new function + with the config parameter added to the signature. + + Args: + func: The function to check and potentially modify + + Returns: + The original function if it has config parameter, or a new function with config parameter added + """ + sig = inspect.signature(func) + if "config" not in sig.parameters: + # Create new signature with config parameter added + new_params = list(sig.parameters.values()) + config_param = inspect.Parameter("config", inspect.Parameter.KEYWORD_ONLY, annotation=RunnableConfig) + new_params.append(config_param) + new_sig = sig.replace(parameters=new_params) + + # Create wrapper with new signature + @functools.wraps(func) + def wrapper_with_config(*args, **kwargs): + # Extract config from kwargs, pass remaining args to original function + kwargs.pop("config", None) # Remove config since original function doesn't expect it + # Call original function without config parameter + result = func(*args, **kwargs) + return result + + # Apply the new signature and update annotations + wrapper_with_config.__signature__ = new_sig + + # Update __annotations__ to include the config parameter for Pydantic compatibility + if not hasattr(wrapper_with_config, "__annotations__"): + wrapper_with_config.__annotations__ = {} + wrapper_with_config.__annotations__.update(getattr(func, "__annotations__", {})) + wrapper_with_config.__annotations__["config"] = RunnableConfig + + logger.debug("Added 'config: RunnableConfig' parameter to function '%s'", func.__name__) + return wrapper_with_config + else: + return func + + +def _build_llm_request(input_messages: list) -> LlmRequest: + """Build LlmRequest from input messages. + + Args: + input_messages: List of input messages + + Returns: + LlmRequest object with converted messages + """ + input_contents = [] + for msg in input_messages: + if hasattr(msg, "content") and msg.content: + if hasattr(msg, "type"): + if msg.type == "human": + input_contents.append(types.Content(role="user", parts=[types.Part.from_text(text=msg.content)])) + elif msg.type == "ai": + input_contents.append(types.Content(role="model", parts=[types.Part.from_text(text=msg.content)])) + elif msg.type == "system": + input_contents.append( + types.Content(role="user", parts=[types.Part.from_text(text=f"System: {msg.content}")])) + if not input_contents: + return None + return LlmRequest( + model="langgraph-llm", + config=types.GenerateContentConfig(), + contents=input_contents, + ) + + +def _build_llm_response(result: Any, output_key: str = "messages") -> Optional[LlmResponse]: + """Build LLM response from function result. + + Args: + result: The result from the LLM function call (always a dict with messages) + output_key: The key to use for extracting messages from result dict + + Returns: + LlmResponse object for tracing, or None if no valid response found + """ + logger.debug("Building LLM response from result: %s", result) + + # Extract the messages from the result dict using the specified key + if not isinstance(result, dict) or output_key not in result: + return None + + messages = result[output_key] + if not isinstance(messages, list) or not messages: + return None + + # Always use the last element from the messages list + last_message = messages[-1] + + # Process the last message if it's an AIMessage + if isinstance(last_message, AIMessage): + # Create content for LlmResponse + parts = [] + usage_metadata = None + + # Extract usage metadata if available + if hasattr(last_message, "usage_metadata") and last_message.usage_metadata: + usage_data = last_message.usage_metadata + usage_metadata = types.GenerateContentResponseUsageMetadata( + prompt_token_count=usage_data.get("input_tokens", 0), + candidates_token_count=usage_data.get("output_tokens", 0), + total_token_count=usage_data.get("total_tokens", 0), + ) + + # Handle tool calls or text content + if hasattr(last_message, "tool_calls") and last_message.tool_calls: + for tool_call in last_message.tool_calls: + parts.append( + types.Part.from_function_call(name=tool_call.get("name", ""), args=tool_call.get("args", {}))) + elif last_message.content: + parts.append(types.Part.from_text(text=last_message.content)) + + if parts: + # Create and return the LlmResponse + return LlmResponse( + content=types.Content(role="model", parts=parts), + usage_metadata=usage_metadata, + partial=False, + ) + + return None + + +def langgraph_llm_node(llm_func=None, *, input_key: str = "messages", output_key: str = "messages"): + """Decorator to wrap LLM functions with TRPC tracing. + + This wrapper automatically adds LLM tracing for functions that call LLMs directly. + Can be used on any function that calls an LLM and returns a response. + + The decorated function should have signature: func(state: State, config: RunnableConfig) + where State contains a field with the conversation history. + + Args: + llm_func: The function that calls an LLM + input_key: The key to use for extracting input messages from state dict (default: "messages") + output_key: The key to use for extracting output messages from result dict (default: "messages") + + Returns: + Wrapped function with automatic LLM tracing + + Example: + @langgraph_llm_node + def chatbot(state: State, config: RunnableConfig): + return {"messages": [llm.invoke(state["messages"])]} + + # With custom keys: + @langgraph_llm_node(input_key="conversation", output_key="response") + def chatbot(state: State, config: RunnableConfig): + return {"response": [llm.invoke(state["conversation"])]} + """ + + def decorator(func): + # Ensure function has config parameter + actual_func = _ensure_config_parameter(func) + + @functools.wraps(actual_func) + def wrapper(*args, **kwargs): + # Check if config is available for tracing + logger.debug("LLM function '%s' executed with args: %s, kwargs: %s", actual_func.__name__, args, kwargs) + config = kwargs.get("config") + # Use tracer span when config is available + with tracer.start_as_current_span("call_llm"): + try: + # Extract state from first argument + state = args[0] if args else {} + input_messages = state.get(input_key, []) if isinstance(state, dict) else [] + + # Call the actual function + result = actual_func(*args, **kwargs) + + # Add LLM tracing + ctx = get_agent_context(config) + + # Build LlmRequest from input messages + llm_request = _build_llm_request(input_messages) + + # Build LLM response with custom output key + llm_response = _build_llm_response(result, output_key) + + # Invoke tracing if we have a valid response + if llm_request and llm_response: + trace_call_llm(ctx, Event.new_id(), llm_request, llm_response) + logger.debug("Added LLM trace for function: %s", actual_func.__name__) + + return result + + except Exception as ex: # pylint: disable=broad-except + logger.error("Could not trace LLM call in %s: %s", actual_func.__name__, ex) + # Still call the original function even if tracing fails + return actual_func(*args, **kwargs) + + return wrapper + + # Support both @langgraph_llm_node and @langgraph_llm_node() syntax + if llm_func is None: + # Called as @langgraph_llm_node(input_key="...", output_key="...") + return decorator + else: + # Called as @langgraph_llm_node + return decorator(llm_func) + + +def langgraph_tool_node(tool_func): + """Decorator to wrap tool functions with TRPC tracing. + + This wrapper adds automatic tool tracing for tool functions. + Should be used AFTER the @tool decorator from LangChain. + + Args: + tool_func: The tool function to wrap + + Returns: + Wrapped function with automatic tool tracing + + Example: + @tool + @langgraph_tool_node + def calculate(operation: str, a: float, b: float) -> str: + # Tool implementation + return result + """ + + # Ensure function has config parameter + actual_func = _ensure_config_parameter(tool_func) + + @functools.wraps(actual_func) + def wrapper(*args, **kwargs): + # Check if config is available for tracing + config = kwargs.get("config") + # Use tracer span when config is available + with tracer.start_as_current_span(f"execute_tool {actual_func.__name__}"): + try: + # Call the actual function + result = actual_func(*args, **kwargs) + + # Add tool tracing + ctx = get_agent_context(config) + + # Create a mock tool for tracing + class ToolTracingMetadata(BaseTool): + + def __init__(self, name: str, description: str): + self._name = name + self.description = description + + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return + + tool_metadata = ToolTracingMetadata(actual_func.__name__, actual_func.__doc__) + + # Create an Event for the tool response + response_data = {"result": result} if not isinstance(result, dict) else result + parts = [types.Part.from_function_response(name=actual_func.__name__, response=response_data)] + + event = Event( + invocation_id=ctx.invocation_id, + author=ctx.agent_name, + branch=ctx.branch, + content=types.Content(role="model", parts=parts), + partial=False, + ) + + # Add tool tracing with kwargs as args + trace_tool_call(tool_metadata, kwargs, event) + logger.debug("Added tool trace for: %s, with args: %s", actual_func.__name__, kwargs) + + return result + + except Exception as ex: # pylint: disable=broad-except + logger.error("Could not trace tool call in %s: %s", actual_func.__name__, ex) + # Still call the original function even if tracing fails + return actual_func(*args, **kwargs) + + return wrapper diff --git a/trpc_agent_sdk/agents/utils/_langgraph_event_writer.py b/trpc_agent_sdk/agents/utils/_langgraph_event_writer.py new file mode 100644 index 000000000..ef4940fab --- /dev/null +++ b/trpc_agent_sdk/agents/utils/_langgraph_event_writer.py @@ -0,0 +1,232 @@ +# 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. +"""LangGraph event writer for emitting trpc Events via StreamWriter.""" + +from enum import Enum +from typing import Any +from typing import Dict + +from google.genai import types + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger + +from ._langgraph import get_agent_context + +# Marker to identify trpc Events in LangGraph custom stream +TRPC_EVENT_MARKER = "__trpc_event__" + +# Marker for event type (text, custom, etc.) +LANGGRAPH_EVENT_TYPE = "__langgraph_event_type__" + + +class LangGraphEventType(str, Enum): + """Enum for LangGraph event types. + + This enum defines the types of events that can be emitted via LangGraphEventWriter. + """ + TEXT = "text" + """Text event type for text messages""" + + CUSTOM = "custom" + """Custom event type for structured data""" + + +class _TrpcEventWrapper: + """Wraps Event for StreamWriter transport. + + This wrapper adds markers that allow LangGraphAgent to detect and extract + trpc Events from the custom stream. + """ + + def __init__(self, event: Event, event_type: LangGraphEventType): + """Initialize the wrapper. + + Args: + event: The trpc Event to wrap + event_type: The type of event + """ + self._event = event + self._event_type = event_type + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for StreamWriter transport. + + Returns: + Dictionary containing the wrapped event with markers + """ + return { + TRPC_EVENT_MARKER: True, + LANGGRAPH_EVENT_TYPE: self._event_type.value, + "event": self._event, + } + + +class LangGraphEventWriter: + """Writer for emitting trpc Events from LangGraph nodes via StreamWriter. + + This class provides a convenient interface for LangGraph nodes to emit + trpc Events that will be properly handled by LangGraphAgent and translated + to protocol-specific events (AG-UI, A2A). + + Usage: + async def my_node(state, config, writer: StreamWriter): + event_writer = LangGraphEventWriter.from_config(writer, config) + event_writer.write_text("Processing...") + event_writer.write_custom({"progress": 50}) + return {"messages": [...]} + """ + + def __init__(self, writer: Any, ctx: InvocationContext): + """Initialize the event writer. + + Args: + writer: LangGraph StreamWriter instance + ctx: The InvocationContext for this invocation + """ + self._writer = writer + self._ctx = ctx + + @classmethod + def from_config(cls, writer: Any, config: Dict[str, Any]) -> "LangGraphEventWriter": + """Create a LangGraphEventWriter from StreamWriter and RunnableConfig. + + Args: + writer: LangGraph StreamWriter instance + config: RunnableConfig containing InvocationContext + + Returns: + LangGraphEventWriter instance + + Raises: + ValueError: If InvocationContext is not found in config + """ + ctx = get_agent_context(config) + return cls(writer, ctx) + + def write_text( + self, + text: str, + *, + partial: bool = True, + thought: bool = False, + ) -> None: + """Write a text event. + + Args: + text: The text content to emit + partial: Whether this is a partial/streaming event (default: True) + thought: Whether this is a thinking/reasoning text (default: False) + """ + part = types.Part.from_text(text=text) + if thought: + part.thought = True + + event = Event( + invocation_id=self._ctx.invocation_id, + author=self._ctx.agent_name, + branch=self._ctx.branch, + content=types.Content(role="model", parts=[part]), + partial=partial, + custom_metadata={ + TRPC_EVENT_MARKER: True, + LANGGRAPH_EVENT_TYPE: LangGraphEventType.TEXT.value + }, + ) + self._emit(event, LangGraphEventType.TEXT) + + def write_custom(self, data: Dict[str, Any]) -> None: + """Write a custom data event. + + Args: + data: Custom data dictionary to emit + """ + event = Event( + invocation_id=self._ctx.invocation_id, + author=self._ctx.agent_name, + branch=self._ctx.branch, + custom_metadata={ + TRPC_EVENT_MARKER: True, + LANGGRAPH_EVENT_TYPE: LangGraphEventType.CUSTOM.value, + "data": data, + }, + partial=True, + ) + self._emit(event, LangGraphEventType.CUSTOM) + + def _emit(self, event: Event, event_type: LangGraphEventType) -> None: + """Internal method to emit event via StreamWriter. + + Args: + event: The Event to emit + event_type: The type of event for detection + """ + wrapper = _TrpcEventWrapper(event, event_type) + self._writer(wrapper.to_dict()) + logger.debug("Emitted LangGraph event: type=%s, invocation_id=%s", event_type.value, event.invocation_id) + + +def is_trpc_event_chunk(chunk_data: Any) -> bool: + """Check if chunk contains a trpc Event from LangGraphEventWriter. + + This function is used by LangGraphAgent to detect events emitted via + LangGraphEventWriter in the custom stream. + + Args: + chunk_data: The chunk data from LangGraph streaming + + Returns: + True if the chunk contains a trpc Event, False otherwise + """ + if isinstance(chunk_data, dict): + return chunk_data.get(TRPC_EVENT_MARKER, False) is True + return False + + +def extract_trpc_event(chunk_data: Dict[str, Any]) -> Event: + """Extract the trpc Event from a custom stream chunk. + + This function is used by LangGraphAgent to extract Events that were + emitted via LangGraphEventWriter. + + Args: + chunk_data: The chunk data containing a wrapped trpc Event + + Returns: + The extracted Event + + Raises: + ValueError: If the chunk does not contain a valid Event + """ + if not is_trpc_event_chunk(chunk_data): + raise ValueError("Chunk does not contain a trpc Event") + + event = chunk_data.get("event") + if not isinstance(event, Event): + raise ValueError(f"Invalid event in chunk: expected Event, got {type(event)}") + + return event + + +def get_event_type(event: Event) -> LangGraphEventType | None: + """Get the LangGraph event type from a trpc Event. + + Args: + event: The trpc Event to extract type from + + Returns: + LangGraphEventType if found, None otherwise + """ + if not event.custom_metadata or LANGGRAPH_EVENT_TYPE not in event.custom_metadata: + return None + + event_type_str = event.custom_metadata.get(LANGGRAPH_EVENT_TYPE) + try: + return LangGraphEventType(event_type_str) + except ValueError: + logger.warning("Unknown LangGraph event type: %s", event_type_str) + return None diff --git a/trpc_agent_sdk/artifacts/__init__.py b/trpc_agent_sdk/artifacts/__init__.py new file mode 100644 index 000000000..f8b1a610e --- /dev/null +++ b/trpc_agent_sdk/artifacts/__init__.py @@ -0,0 +1,34 @@ +# 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. +"""Artifact management module. + +This module provides artifact management functionality including: +- Abstract artifact service interfaces +- In-memory artifact service implementation +""" + +from trpc_agent_sdk.abc import ArtifactServiceABC as BaseArtifactService + +from ._in_memory_artifact_service import InMemoryArtifactService +from ._utils import ParsedArtifactUri +from ._utils import artifact_path +from ._utils import create_artifact_uri +from ._utils import file_has_user_namespace +from ._utils import get_artifact_uri +from ._utils import is_artifact_ref +from ._utils import parse_artifact_uri + +__all__ = [ + "BaseArtifactService", + "InMemoryArtifactService", + "ParsedArtifactUri", + "artifact_path", + "create_artifact_uri", + "file_has_user_namespace", + "get_artifact_uri", + "is_artifact_ref", + "parse_artifact_uri", +] diff --git a/trpc_agent_sdk/artifacts/_in_memory_artifact_service.py b/trpc_agent_sdk/artifacts/_in_memory_artifact_service.py new file mode 100644 index 000000000..3c9674b52 --- /dev/null +++ b/trpc_agent_sdk/artifacts/_in_memory_artifact_service.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# +# Copyright @ 2025 Tencent.com +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""An in-memory implementation of the artifact service.""" +from typing import Any +from typing import Optional +from typing_extensions import override + +from pydantic import BaseModel +from pydantic import Field +from trpc_agent_sdk.abc import ArtifactEntry +from trpc_agent_sdk.abc import ArtifactId +from trpc_agent_sdk.abc import ArtifactServiceABC +from trpc_agent_sdk.abc import ArtifactVersion +from trpc_agent_sdk.types import Part + +from ._utils import artifact_path +from ._utils import create_artifact_uri +from ._utils import is_artifact_ref +from ._utils import parse_artifact_uri + + +class InMemoryArtifactService(ArtifactServiceABC, BaseModel): + """An in-memory implementation of the artifact service.""" + + artifacts: dict[str, list[ArtifactEntry]] = Field(default_factory=dict) + + @override + async def save_artifact( + self, + *, + artifact_id: ArtifactId, + artifact: Part, + metadata: Optional[dict[str, Any]] = None, + ) -> int: + path = artifact_path(artifact_id) + if path not in self.artifacts: + self.artifacts[path] = [] + version = len(self.artifacts[path]) + canonical_uri = create_artifact_uri(artifact_id, version) + artifact_version = ArtifactVersion( + version=version, + canonical_uri=canonical_uri, + ) + if metadata: + artifact_version.custom_metadata = metadata + + if artifact.inline_data is not None: + artifact_version.mime_type = artifact.inline_data.mime_type + elif artifact.text is not None: + artifact_version.mime_type = "text/plain" + elif artifact.file_data is not None: + if is_artifact_ref(artifact): + if not parse_artifact_uri(artifact.file_data.file_uri): + raise ValueError(f"Invalid artifact reference URI: {artifact.file_data.file_uri}") + # If it's a valid artifact URI, we store the artifact part as-is. + # And we don't know the mime type until we load it. + else: + artifact_version.mime_type = artifact.file_data.mime_type + else: + raise ValueError("Not supported artifact type.") + + self.artifacts[path].append(ArtifactEntry(data=artifact, version=artifact_version)) + return version + + @override + async def load_artifact( + self, + *, + artifact_id: ArtifactId, + version: Optional[int] = None, + ) -> Optional[ArtifactEntry]: + path = artifact_path(artifact_id) + versions = self.artifacts.get(path) + if not versions: + return None + if version is None: + version = -1 + + try: + artifact_entry = versions[version] + except IndexError: + return None + + if artifact_entry is None: + return None + + # Resolve artifact reference if needed. + artifact_data = artifact_entry.data + if is_artifact_ref(artifact_data): + parsed_uri = parse_artifact_uri(artifact_data.file_data.file_uri) + if not parsed_uri: + raise ValueError("Invalid artifact reference URI:" + f" {artifact_data.file_data.file_uri}") + return await self.load_artifact( + artifact_id=ArtifactId( + app_name=parsed_uri.app_name, + user_id=parsed_uri.user_id, + session_id=parsed_uri.session_id, + filename=parsed_uri.filename, + ), + version=parsed_uri.version, + ) + + is_empty_part = artifact_data == Part() + is_empty_text_part = artifact_data == Part(text="") + has_empty_inline_data = bool(artifact_data.inline_data and not artifact_data.inline_data.data) + if is_empty_part or is_empty_text_part or has_empty_inline_data: + return None + if artifact_entry.version.mime_type is None: + artifact_entry.version.mime_type = "application/octet-stream" + return artifact_entry + + @override + async def list_artifact_keys(self, *, artifact_id: ArtifactId) -> list[str]: + user_namespace_prefix = f"{artifact_id.app_name}/{artifact_id.user_id}/user/" + session_prefix = (f"{artifact_id.app_name}/{artifact_id.user_id}/{artifact_id.session_id}/" + if artifact_id.session_id else None) + filenames = [] + for path in self.artifacts: + if session_prefix and path.startswith(session_prefix): + filename = path.removeprefix(session_prefix) + filenames.append(filename) + elif path.startswith(user_namespace_prefix): + filename = path.removeprefix(user_namespace_prefix) + filenames.append(filename) + return sorted(filenames) + + @override + async def delete_artifact( + self, + *, + artifact_id: ArtifactId, + ) -> None: + path = artifact_path(artifact_id) + if not self.artifacts.get(path): + return None + self.artifacts.pop(path, None) + + @override + async def list_versions( + self, + *, + artifact_id: ArtifactId, + ) -> list[int]: + path = artifact_path(artifact_id) + versions = self.artifacts.get(path) + if not versions: + return [] + return list(range(len(versions))) + + @override + async def list_artifact_versions( + self, + *, + artifact_id: ArtifactId, + ) -> list[ArtifactVersion]: + path = artifact_path(artifact_id) + entries = self.artifacts.get(path) + if not entries: + return [] + return [entry.version for entry in entries] + + @override + async def get_artifact_version( + self, + *, + artifact_id: ArtifactId, + version: Optional[int] = None, + ) -> Optional[ArtifactVersion]: + path = artifact_path(artifact_id) + entries = self.artifacts.get(path) + if not entries: + return None + + if version is None: + version = -1 + try: + return entries[version].version + except IndexError: + return None diff --git a/trpc_agent_sdk/artifacts/_utils.py b/trpc_agent_sdk/artifacts/_utils.py new file mode 100644 index 000000000..25b3891e4 --- /dev/null +++ b/trpc_agent_sdk/artifacts/_utils.py @@ -0,0 +1,161 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility functions for handling artifact URIs.""" + +from __future__ import annotations + +import re +from typing import NamedTuple +from typing import Optional + +from google.genai import types +from trpc_agent_sdk.abc import ArtifactId + + +class ParsedArtifactUri(NamedTuple): + """The result of parsing an artifact URI.""" + + app_name: str + user_id: str + session_id: Optional[str] + filename: str + version: int + + +_SESSION_SCOPED_ARTIFACT_URI_RE = re.compile( + r"artifact://apps/([^/]+)/users/([^/]+)/sessions/([^/]+)/artifacts/([^/]+)/versions/(\d+)") +_USER_SCOPED_ARTIFACT_URI_RE = re.compile(r"artifact://apps/([^/]+)/users/([^/]+)/artifacts/([^/]+)/versions/(\d+)") + + +def parse_artifact_uri(uri: str) -> Optional[ParsedArtifactUri]: + """Parses an artifact URI. + + Args: + uri: The artifact URI to parse. + + Returns: + A ParsedArtifactUri if parsing is successful, None otherwise. + """ + if not uri or not uri.startswith("artifact://"): + return None + + match = _SESSION_SCOPED_ARTIFACT_URI_RE.match(uri) + if match: + return ParsedArtifactUri( + app_name=match.group(1), + user_id=match.group(2), + session_id=match.group(3), + filename=match.group(4), + version=int(match.group(5)), + ) + + match = _USER_SCOPED_ARTIFACT_URI_RE.match(uri) + if match: + return ParsedArtifactUri( + app_name=match.group(1), + user_id=match.group(2), + session_id=None, + filename=match.group(3), + version=int(match.group(4)), + ) + + return None + + +def get_artifact_uri(artifact_id: ArtifactId, version: int) -> str: + """Constructs an artifact URI. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + filename: The name of the artifact file. + version: The version of the artifact. + session_id: The ID of the session. + + Returns: + The constructed artifact URI. + """ + prefix = f"artifact://apps/{artifact_id.app_name}/users/{artifact_id.user_id}" + if artifact_id.session_id: + return f"{prefix}/sessions/{artifact_id.session_id}/artifacts/{artifact_id.filename}/versions/{version}" + return f"{prefix}/artifacts/{artifact_id.filename}/versions/{version}" + + +def is_artifact_ref(artifact: types.Part) -> bool: + """Checks if an artifact part is an artifact reference. + + Args: + artifact: The artifact part to check. + + Returns: + True if the artifact part is an artifact reference, False otherwise. + """ + file_data = artifact.file_data + file_uri = file_data.file_uri if file_data else "" + return bool(file_uri and file_uri.startswith("artifact://")) + + +def file_has_user_namespace(filename: str) -> bool: + """Checks if the filename has a user namespace. + + Args: + filename: The filename to check. + + Returns: + True if the filename has a user namespace (starts with "user:"), + False otherwise. + """ + return filename.startswith("user:") + + +def artifact_path(artifact_id: ArtifactId) -> str: + """Constructs the artifact path. + +Args: + artifact_id: The identifier for the artifact. + +Returns: + The constructed artifact path. +""" + if file_has_user_namespace(artifact_id.filename): + return f"{artifact_id.app_name}/{artifact_id.user_id}/user/{artifact_id.filename}" + return f"{artifact_id.app_name}/{artifact_id.user_id}/{artifact_id.session_id}/{artifact_id.filename}" + + +def create_artifact_uri(artifact_id: ArtifactId, version: int) -> str: + """Creates an artifact URI. + + Args: + app_name: The name of the application. + user_id: The ID of the user. + filename: The name of the artifact file. + version: The version of the artifact. + session_id: The ID of the session. + + Returns: + The constructed artifact URI. + """ + prefix = f"memory://apps/{artifact_id.app_name}/users/{artifact_id.user_id}" + if file_has_user_namespace(artifact_id.filename): + return f"{prefix}/artifacts/{artifact_id.filename}/versions/{version}" + return f"{prefix}/sessions/{artifact_id.session_id}/artifacts/{artifact_id.filename}/versions/{version}" diff --git a/trpc_agent_sdk/cancel/__init__.py b/trpc_agent_sdk/cancel/__init__.py new file mode 100644 index 000000000..18c40453f --- /dev/null +++ b/trpc_agent_sdk/cancel/__init__.py @@ -0,0 +1,28 @@ +# 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. +"""Cancellation support for TRPC Agent framework.""" + +from ._cancel import SessionKey +from ._cancel import cancel_run +from ._cancel import cleanup_run +from ._cancel import get_cancel_event +from ._cancel import is_run_cancelled +from ._cancel import raise_if_cancelled +from ._cancel import register_run +from ._session_utils import cleanup_incomplete_function_calls +from ._session_utils import handle_cancellation_session_cleanup + +__all__ = [ + "SessionKey", + "cancel_run", + "cleanup_run", + "get_cancel_event", + "is_run_cancelled", + "raise_if_cancelled", + "register_run", + "cleanup_incomplete_function_calls", + "handle_cancellation_session_cleanup", +] diff --git a/trpc_agent_sdk/cancel/_cancel.py b/trpc_agent_sdk/cancel/_cancel.py new file mode 100644 index 000000000..88317f5a5 --- /dev/null +++ b/trpc_agent_sdk/cancel/_cancel.py @@ -0,0 +1,226 @@ +# 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. +"""Run cancellation manager for TRPC Agent framework. + +This module provides the core cancellation mechanism for agent runs, including +session-based tracking and cooperative cancellation support. +""" + +import asyncio +from dataclasses import dataclass +from typing import Dict +from typing import Optional + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.utils import SingletonBase + + +@dataclass(frozen=True) +class SessionKey: + """Immutable key for identifying a session.""" + app_name: str + user_id: str + session_id: str + + +class _RunCancellationManager(SingletonBase): + """Manages cancellation state for agent runs. + + This manager is async-safe and uses asyncio primitives for synchronization. + It tracks active runs and their cancellation status using SessionKey directly. + """ + + def __init__(self): + super().__init__() + # Maps session key to cancellation event + self._cancelled: Dict[SessionKey, asyncio.Event] = {} + # Maps session key to cleanup completion event (for cancel_run_async to wait on) + self._cleanup_events: Dict[SessionKey, asyncio.Event] = {} + self._lock = asyncio.Lock() + + async def register_run( + self, + app_name: str, + user_id: str, + session_id: str, + ) -> SessionKey: + """Register a new run for cancellation tracking. + + Args: + app_name: The application name. + user_id: The user ID. + session_id: The session ID. + + Returns: + SessionKey for use in cancellation checks. + """ + session_key = SessionKey(app_name, user_id, session_id) + async with self._lock: + # Create cancellation event for this run + self._cancelled[session_key] = asyncio.Event() + logger.debug("Registered run for session (%s, %s, %s)", app_name, user_id, session_id) + return session_key + + async def cancel_run( + self, + app_name: str, + user_id: str, + session_id: str, + ) -> Optional[asyncio.Event]: + """Request cancellation of a run by session info. + + This is the primary cancellation API for users. + + Args: + app_name: The application name. + user_id: The user ID. + session_id: The session ID. + + Returns: + An asyncio.Event that will be set when cleanup_run is called, + or None if no active run found for this session. + """ + session_key = SessionKey(app_name, user_id, session_id) + async with self._lock: + if session_key in self._cancelled: + self._cancelled[session_key].set() + # Create cleanup event for waiting + cleanup_event = asyncio.Event() + self._cleanup_events[session_key] = cleanup_event + logger.info("Run marked for cancellation (app_name: %s)(user: %s)(session: %s)", app_name, user_id, + session_id) + return cleanup_event + else: + logger.debug("No active run found for session (%s, %s, %s)", app_name, user_id, session_id) + return None + + async def is_cancelled(self, session_key: SessionKey) -> bool: + """Check if a run is cancelled. + + This is an async method that acquires the lock to safely check + cancellation status. + + Args: + session_key: The session key to check. + + Returns: + True if cancelled, False otherwise. + """ + async with self._lock: + event = self._cancelled.get(session_key) + cancel_flag = event.is_set() if event else False + return cancel_flag + + async def cleanup_run( + self, + app_name: str, + user_id: str, + session_id: str, + ) -> None: + """Remove a run from tracking. + + Should be called when a run completes (normally or cancelled). + + Args: + app_name: The application name. + user_id: The user ID. + session_id: The session ID. + """ + session_key = SessionKey(app_name, user_id, session_id) + async with self._lock: + if session_key in self._cancelled: + del self._cancelled[session_key] + # Signal cleanup completion to any waiters + if session_key in self._cleanup_events: + self._cleanup_events[session_key].set() + del self._cleanup_events[session_key] + logger.debug("Cleaned up run from cancellation tracking (session: %s)", session_id) + + def get_active_sessions(self) -> Dict[tuple[str, str, str], bool]: + """Get all active sessions and their cancellation status. + + Returns: + Dict mapping (app_name, user_id, session_id) to is_cancelled status. + """ + return {(k.app_name, k.user_id, k.session_id): v.is_set() for k, v in self._cancelled.items()} + + async def get_cancel_event(self, session_key: SessionKey) -> Optional[asyncio.Event]: + """Get the cancellation event for a session. + + Args: + session_key: The session key to get the event for. + + Returns: + The asyncio.Event that will be set when cancellation is requested, + or None if the session is not registered. + """ + async with self._lock: + event = self._cancelled.get(session_key) + return event if event else None + + +# Module-level singleton +_manager = _RunCancellationManager() + + +async def register_run( + app_name: str, + user_id: str, + session_id: str, +) -> SessionKey: + """Register a run for cancellation tracking.""" + return await _manager.register_run(app_name, user_id, session_id) + + +async def cancel_run( + app_name: str, + user_id: str, + session_id: str, +) -> Optional[asyncio.Event]: + """Request cancellation of a run by session info. + + Returns: + An asyncio.Event that will be set when cleanup_run is called, + or None if no active run found for this session. + """ + return await _manager.cancel_run(app_name, user_id, session_id) + + +async def is_run_cancelled(session_key: SessionKey) -> bool: + """Check if a run is cancelled.""" + return await _manager.is_cancelled(session_key) + + +async def cleanup_run( + app_name: str, + user_id: str, + session_id: str, +) -> None: + """Remove a run from tracking.""" + await _manager.cleanup_run(app_name, user_id, session_id) + + +async def raise_if_cancelled(session_key: SessionKey) -> None: + """Check and raise RunCancelledException if cancelled. + + This is the primary checkpoint function to be called throughout + agent execution. + + Args: + session_key: The session key to check. + + Raises: + RunCancelledException: If the run is cancelled. + """ + if await _manager.is_cancelled(session_key): + from trpc_agent_sdk.exceptions import RunCancelledException + logger.info("Cancelling run for session %s", session_key.session_id) + raise RunCancelledException(f"Run for session {session_key.session_id} was cancelled") + + +async def get_cancel_event(session_key: SessionKey) -> Optional[asyncio.Event]: + """Get the cancellation event for a session.""" + return await _manager.get_cancel_event(session_key) diff --git a/trpc_agent_sdk/cancel/_session_utils.py b/trpc_agent_sdk/cancel/_session_utils.py new file mode 100644 index 000000000..7b5a235b0 --- /dev/null +++ b/trpc_agent_sdk/cancel/_session_utils.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. +"""Session utilities for handling cancellation cleanup. + +This module provides utilities for cleaning up session state when +runs are cancelled, ensuring consistency in the session history. +""" + +from typing import Optional + +from trpc_agent_sdk.abc import SessionABC +from trpc_agent_sdk.abc import SessionServiceABC +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +_CANCELING_SUFFIX = "Detect user cancel the agent execution." + + +async def cleanup_incomplete_function_calls(session: SessionABC) -> None: + """Remove function_calls from session.events that have no matching function_response. + + When cancellation occurs after tool execution, some function_calls may not + have been executed yet. These incomplete calls should be removed from the + session to maintain consistency for subsequent conversations. + + Args: + session: The session to clean up + """ + # Step 1: Collect all function_response IDs in the session + response_ids: set[str] = set() + for event in session.events: + func_responses = event.get_function_responses() + for func_response in func_responses: + response_ids.add(func_response.id) + + # Step 2: Find and remove incomplete function_calls + for event in session.events: + func_calls = event.get_function_calls() + if not func_calls: + continue + + # Find calls that have no corresponding response + incomplete_call_ids = {fc.id for fc in func_calls if fc.id not in response_ids} + if incomplete_call_ids and event.content and event.content.parts: + # Filter out parts with incomplete function_calls + original_parts_count = len(event.content.parts) + event.content.parts = [ + part for part in event.content.parts + if not (part.function_call and part.function_call.id in incomplete_call_ids) + ] + if len(event.content.parts) != original_parts_count: + logger.debug("Removed %s incomplete function_calls from event %s", + original_parts_count - len(event.content.parts), event.id) + + +async def handle_cancellation_session_cleanup( + session: SessionABC, + session_service: SessionServiceABC, + invocation_id: str, + agent_name: str, + branch: Optional[str], + temp_text: str = "", +) -> None: + """Handle session cleanup when a run is cancelled. + + This function handles two cancellation scenarios: + 1. Cancelled during LLM streaming (temp_text not empty): + - Save accumulated partial text with cancellation suffix + 2. Cancelled after tool execution or between turns (temp_text empty): + - Cleanup incomplete function_calls + - Add cancellation message + + Args: + session: The session to update + session_service: The session service used to persist changes + invocation_id: The invocation ID of the cancelled run + agent_name: The name of the agent that was running + branch: The branch of the event (optional) + temp_text: Accumulated partial text from streaming (empty if not streaming) + """ + if temp_text: + # Scenario A: Cancelled during LLM streaming + logger.debug("Handling cancellation during LLM streaming (accumulated %s chars)", len(temp_text)) + cancel_content = Content(parts=[Part.from_text(text=f"{temp_text}\n\n{_CANCELING_SUFFIX}")]) + cancel_event = Event( + invocation_id=invocation_id, + author=agent_name, + content=cancel_content, + branch=branch, + partial=False, + ) + await session_service.append_event(session=session, event=cancel_event) + else: + # Scenario B: Cancelled after tool execution or between turns + logger.debug("Handling cancellation after tool execution or between turns") + await cleanup_incomplete_function_calls(session) + cancel_event = Event( + invocation_id=invocation_id, + author=agent_name, + content=Content(parts=[Part.from_text(text=_CANCELING_SUFFIX)]), + branch=branch, + partial=False, + ) + await session_service.append_event(session=session, event=cancel_event) diff --git a/trpc_agent_sdk/code_executors/__init__.py b/trpc_agent_sdk/code_executors/__init__.py new file mode 100644 index 000000000..eacd4e7c1 --- /dev/null +++ b/trpc_agent_sdk/code_executors/__init__.py @@ -0,0 +1,180 @@ +# 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. +"""Code Executors package initialization module. + +This module exports all public interfaces of the code execution system, +including base classes and implementations. +""" + +from ._artifacts import load_artifact_helper +from ._artifacts import parse_artifact_ref +from ._artifacts import save_artifact_helper +from ._base_code_executor import BaseCodeExecutor +from ._base_workspace_runtime import BaseProgramRunner +from ._base_workspace_runtime import BaseWorkspaceFS +from ._base_workspace_runtime import BaseWorkspaceManager +from ._base_workspace_runtime import BaseWorkspaceRuntime +from ._base_workspace_runtime import DefaultWorkspace +from ._base_workspace_runtime import new_default_workspace_runtime +from ._base_workspace_runtime import WorkspaceRuntimeResolver +from ._base_workspace_runtime import get_workspace_runtime_with_resolver +from ._code_executor_context import CodeExecutorContext +from ._constants import DEFAULT_CREATE_TIMEOUT_SEC +from ._constants import DEFAULT_FILE_MODE +from ._constants import DEFAULT_INPUTS_CONTAINER +from ._constants import DEFAULT_MAX_FILES +from ._constants import DEFAULT_MAX_TOTAL_BYTES +from ._constants import DEFAULT_RM_TIMEOUT_SEC +from ._constants import DEFAULT_RUN_CONTAINER_BASE +from ._constants import DEFAULT_SKILLS_CONTAINER +from ._constants import DEFAULT_STAGE_TIMEOUT_SEC +from ._constants import DEFAULT_TIMEOUT_SEC +from ._constants import DIR_OUT +from ._constants import DIR_RUNS +from ._constants import DIR_SKILLS +from ._constants import DIR_WORK +from ._constants import ENV_OUTPUT_DIR +from ._constants import ENV_RUN_DIR +from ._constants import ENV_SKILLS_DIR +from ._constants import ENV_SKILL_NAME +from ._constants import ENV_WORK_DIR +from ._constants import MAX_READ_SIZE_BYTES +from ._constants import META_FILE_NAME +from ._constants import TMP_FILE_NAME +from ._constants import WORKSPACE_ENV_DIR_KEY +from ._program_session import BaseProgramSession +from ._program_session import DEFAULT_EXEC_YIELD_MS +from ._program_session import DEFAULT_IO_YIELD_MS +from ._program_session import DEFAULT_POLL_LINES +from ._program_session import DEFAULT_SESSION_KILL_SEC +from ._program_session import DEFAULT_SESSION_TTL_SEC +from ._program_session import PROGRAM_STATUS_EXITED +from ._program_session import PROGRAM_STATUS_RUNNING +from ._program_session import ProgramLog +from ._program_session import ProgramPoll +from ._program_session import ProgramState +from ._program_session import poll_line_limit +from ._program_session import wait_for_program_output +from ._program_session import yield_duration_ms +from ._types import CodeBlock +from ._types import CodeBlockDelimiter +from ._types import CodeExecutionInput +from ._types import CodeExecutionResult +from ._types import CodeFile +from ._types import ManifestFileRef +from ._types import ManifestOutput +from ._types import WorkspaceCapabilities +from ._types import WorkspaceInfo +from ._types import WorkspaceInputSpec +from ._types import WorkspaceOutputSpec +from ._types import WorkspacePutFileInfo +from ._types import WorkspaceResourceLimits +from ._types import WorkspaceRunProgramSpec +from ._types import WorkspaceRunResult +from ._types import WorkspaceStageOptions +from ._types import create_code_execution_result +from .container import ContainerClient +from .container import ContainerCodeExecutor +from .container import ContainerConfig +from .container import ContainerProgramRunner +from .container import ContainerWorkspaceFS +from .container import ContainerWorkspaceManager +from .container import ContainerWorkspaceRuntime +from .container import RuntimeConfig +from .container import create_container_workspace_runtime +from .local import LocalProgramRunner +from .local import LocalWorkspaceFS +from .local import LocalWorkspaceManager +from .local import LocalWorkspaceRuntime +from .local import UnsafeLocalCodeExecutor +from .local import create_local_workspace_runtime +from .utils import CodeExecutionUtils + +__all__ = [ + "load_artifact_helper", + "parse_artifact_ref", + "save_artifact_helper", + "BaseCodeExecutor", + "BaseProgramRunner", + "BaseWorkspaceFS", + "BaseWorkspaceManager", + "BaseWorkspaceRuntime", + "DefaultWorkspace", + "new_default_workspace_runtime", + "WorkspaceRuntimeResolver", + "get_workspace_runtime_with_resolver", + "CodeExecutorContext", + "DEFAULT_CREATE_TIMEOUT_SEC", + "DEFAULT_FILE_MODE", + "DEFAULT_INPUTS_CONTAINER", + "DEFAULT_MAX_FILES", + "DEFAULT_MAX_TOTAL_BYTES", + "DEFAULT_RM_TIMEOUT_SEC", + "DEFAULT_RUN_CONTAINER_BASE", + "DEFAULT_SKILLS_CONTAINER", + "DEFAULT_STAGE_TIMEOUT_SEC", + "DEFAULT_TIMEOUT_SEC", + "DIR_OUT", + "DIR_RUNS", + "DIR_SKILLS", + "DIR_WORK", + "ENV_OUTPUT_DIR", + "ENV_RUN_DIR", + "ENV_SKILLS_DIR", + "ENV_SKILL_NAME", + "ENV_WORK_DIR", + "MAX_READ_SIZE_BYTES", + "META_FILE_NAME", + "TMP_FILE_NAME", + "WORKSPACE_ENV_DIR_KEY", + "BaseProgramSession", + "DEFAULT_EXEC_YIELD_MS", + "DEFAULT_IO_YIELD_MS", + "DEFAULT_POLL_LINES", + "DEFAULT_SESSION_KILL_SEC", + "DEFAULT_SESSION_TTL_SEC", + "PROGRAM_STATUS_EXITED", + "PROGRAM_STATUS_RUNNING", + "ProgramLog", + "ProgramPoll", + "ProgramState", + "poll_line_limit", + "wait_for_program_output", + "yield_duration_ms", + "CodeBlock", + "CodeBlockDelimiter", + "CodeExecutionInput", + "CodeExecutionResult", + "CodeFile", + "ManifestFileRef", + "ManifestOutput", + "WorkspaceCapabilities", + "WorkspaceInfo", + "WorkspaceInputSpec", + "WorkspaceOutputSpec", + "WorkspacePutFileInfo", + "WorkspaceResourceLimits", + "WorkspaceRunProgramSpec", + "WorkspaceRunResult", + "WorkspaceStageOptions", + "create_code_execution_result", + "ContainerClient", + "ContainerCodeExecutor", + "ContainerConfig", + "ContainerProgramRunner", + "ContainerWorkspaceFS", + "ContainerWorkspaceManager", + "ContainerWorkspaceRuntime", + "RuntimeConfig", + "create_container_workspace_runtime", + "LocalProgramRunner", + "LocalWorkspaceFS", + "LocalWorkspaceManager", + "LocalWorkspaceRuntime", + "UnsafeLocalCodeExecutor", + "create_local_workspace_runtime", + "CodeExecutionUtils", +] diff --git a/trpc_agent_sdk/code_executors/_artifacts.py b/trpc_agent_sdk/code_executors/_artifacts.py new file mode 100644 index 000000000..3c1087b26 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_artifacts.py @@ -0,0 +1,90 @@ +# 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. +""" +Artifact helper functions for loading and saving artifacts. + +This module provides helper functions to interact with the artifact service +through context, enabling artifact resolution without importing higher-level packages. +""" + +from typing import Optional +from typing import Tuple + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import Blob +from trpc_agent_sdk.types import Part + + +async def load_artifact_helper(ctx: InvocationContext, + name: str, + version: Optional[int] = None) -> Optional[Tuple[bytes, int]]: + """ + Resolve artifact name@version via callback context. + + If version is None, loads latest. Returns data, mime, actual version. + + Args: + ctx: The context containing artifact service + name: The artifact name + version: Optional version number. If None, loads latest. + + Returns: + The artifact. + """ + if not ctx: + raise ValueError("ctx is required") + artifact_entry = await ctx.load_artifact(name, version) + if artifact_entry is None: + return None + if version is None: + version = artifact_entry.version.version + return artifact_entry.data.inline_data.data, version or 0 + + +def parse_artifact_ref(ref: str) -> Tuple[str, Optional[int]]: + """ + Split "name@version" into name and optional version. + + Args: + ref: The artifact reference string (e.g., "name@version" or "name") + + Returns: + The artifact name and version. + """ + parts = ref.split("@") + name = parts[0] + if not name: + raise ValueError(f"invalid ref: {ref}") + + if len(parts) == 1: + return name, None + + if len(parts) >= 2: + # Try to parse version as integer + version_str = "".join(parts[1:]) + if not version_str.isdigit(): + return name, None + + return name, int(version_str) + + raise ValueError(f"invalid ref: {ref}") + + +async def save_artifact_helper(ctx: InvocationContext, filename: str, data: bytes, mime: str) -> int: + """ + Save a file as artifact using callback context. + + Args: + ctx: The context containing artifact service + filename: The filename of the artifact + data: The binary data to save + mime: The MIME type of the data + + Returns: + The version of the artifact. + """ + artifact = Part(inline_data=Blob(data=data, mime_type=mime), ) + return await ctx.save_artifact(filename, artifact) diff --git a/trpc_agent_sdk/code_executors/_base_code_executor.py b/trpc_agent_sdk/code_executors/_base_code_executor.py new file mode 100644 index 000000000..6b533112f --- /dev/null +++ b/trpc_agent_sdk/code_executors/_base_code_executor.py @@ -0,0 +1,120 @@ +# 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. +"""Base code executor for TRPC Agent framework. + +This module provides the abstract base class for all code executors. +The code executor allows the agent to execute code blocks from model responses +and incorporate the execution results into the final response. +""" + +from __future__ import annotations + +import abc +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field +from trpc_agent_sdk.context import InvocationContext + +from ._base_workspace_runtime import BaseWorkspaceRuntime +from ._types import CodeBlockDelimiter +from ._types import CodeExecutionInput +from ._types import CodeExecutionResult + +DEFAULT_CODE_BLOCK_DELIMITERS: tuple[CodeBlockDelimiter, ...] = ( + CodeBlockDelimiter(start="```tool_code\n", end="\n```"), + CodeBlockDelimiter(start="```python\n", end="\n```"), +) + + +class BaseCodeExecutor(BaseModel): + """Abstract base class for all code executors. + + The code executor allows the agent to execute code blocks from model responses + and incorporate the execution results into the final response. + + Attributes: + optimize_data_file: If true, extract and process data files from the model + request and attach them to the code executor. Supported data file + MimeTypes are [text/csv]. Default to False. + stateful: Whether the code executor is stateful. Default to False. + error_retry_attempts: The number of attempts to retry on consecutive code + execution errors. Default to 2. + code_block_delimiters: The list of the enclosing delimiters to identify the + code blocks. + execution_result_delimiters: The delimiters to format the code execution + result. + """ + + model_config = {"arbitrary_types_allowed": True} + + optimize_data_file: bool = False + """If true, extract and process data files from the model request + and attach them to the code executor. + + Supported data file MimeTypes are [text/csv]. + Default to False. + """ + + stateful: bool = False + """Whether the code executor is stateful. Default to False.""" + + error_retry_attempts: int = 2 + """The number of attempts to retry on consecutive code execution errors. Default to 2.""" + + execute_once_per_invocation: bool = False + """Whether to execute model-extracted code at most once per invocation. + + When enabled, post-processing code execution runs only for the first + detected code block in a single ``invocation_id`` and skips subsequent + auto-execution attempts for that invocation. + """ + + code_block_delimiters: list[CodeBlockDelimiter] = Field(default_factory=lambda: list(DEFAULT_CODE_BLOCK_DELIMITERS)) + """The list of the enclosing delimiters to identify the code blocks. + + For example, the delimiter ('```python\\n', '\\n```') can be + used to identify code blocks with the following format:: + + ```python + print("hello") + ``` + """ + + execution_result_delimiters: list[CodeBlockDelimiter] = [ + CodeBlockDelimiter(start="```tool_output\n", end="\n```"), + ] + """The delimiters to format the code execution result.""" + + workspace_runtime: Optional[BaseWorkspaceRuntime] = None + """The workspace runtime for the code execution.""" + + ignore_codes: list[str] = [] + """The list of codes to ignore in the code execution.""" + + @abc.abstractmethod + async def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + """Executes code and return the code execution result. + + Args: + invocation_context: The invocation context of the code execution. + code_execution_input: The code execution input. + + Returns: + The code execution result. + """ + + def code_block_delimiter(self) -> list[CodeBlockDelimiter]: + """Return the code block delimiter used by this executor. + + Returns: + CodeBlockDelimiter instance + """ + return self.code_block_delimiters diff --git a/trpc_agent_sdk/code_executors/_base_workspace_runtime.py b/trpc_agent_sdk/code_executors/_base_workspace_runtime.py new file mode 100644 index 000000000..fd6c89759 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_base_workspace_runtime.py @@ -0,0 +1,558 @@ +# 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. +""" +WorkspaceInfo types and helpers for code execution. + +This module defines workspace types, policies, and interfaces for managing +isolated execution environments. +""" + +from abc import ABC +from abc import abstractmethod +from typing import Awaitable +from typing import Callable +from typing import TypeAlias +from typing import List +from typing import Optional +from typing import Tuple + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger + +from ._artifacts import save_artifact_helper +from ._constants import DEFAULT_MAX_FILES +from ._constants import DEFAULT_MAX_TOTAL_BYTES +from ._constants import MAX_READ_SIZE_BYTES +from ._types import CodeFile +from ._types import ManifestFileRef +from ._types import ManifestOutput +from ._types import WorkspaceCapabilities +from ._types import WorkspaceInfo +from ._types import WorkspaceInputSpec +from ._types import WorkspaceOutputSpec +from ._types import WorkspacePutFileInfo +from ._types import WorkspaceRunProgramSpec +from ._types import WorkspaceRunResult +from ._types import WorkspaceStageOptions + +RunEnvProvider = Callable[[Optional[InvocationContext]], dict[str, str]] + +ManifestFetcher: TypeAlias = Callable[[str, int], Awaitable[Tuple[bytes, int]]] +"""Async callable ``(absolute_path, max_bytes) -> (data, raw_size)``. + +Contract: +- ``data`` is the file's content truncated to at most ``max_bytes``. If the + underlying medium cannot cheaply report the full size (e.g. a streaming + read), the fetcher may return ``raw_size = len(data)``; callers that care + about truncation must then treat ``len(data) == max_bytes`` as "possibly + truncated". +- ``raw_size`` is the size of the file on the underlying medium before any + truncation, used only to decide ``truncated`` / ``limits_hit`` flags. +- The fetcher must *not* raise for merely-empty files; it should raise only + for genuine I/O errors so the backend can surface a meaningful message. +""" + + +class BaseWorkspaceManager(ABC): + """ + Handles workspace lifecycle. + """ + + @abstractmethod + async def create_workspace( + self, + exec_id: str, + ctx: Optional[InvocationContext] = None, + ) -> WorkspaceInfo: + """ + Create a new workspace. + """ + pass + + @abstractmethod + async def cleanup( + self, + exec_id: str, + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Clean up a workspace. + """ + pass + + +class BaseWorkspaceFS(ABC): + """ + Performs file operations within a workspace. + + Subclasses are expected to implement the abstract operations using + whatever I/O mechanism their backend exposes (direct filesystem, + docker ``get_archive``, Cube RPC, ...). The shared *post-fetch* + pipeline — turning raw matched paths plus a fetcher into + :class:`CodeFile` / :class:`ManifestOutput` models — is provided + here as protected helpers (:meth:`_build_code_files`, + :meth:`_build_manifest_output`) so subclasses can call them + directly without re-implementing the limit / inline / save / MIME + sniffing plumbing, and can override them when they need a tweak. + """ + + @staticmethod + def _relativize(ws_path: str, full_path: str) -> str: + """Return ``full_path`` stripped of the ``ws.path + "/"`` prefix. + + Kept as a single helper so every backend produces identical + relative paths in :class:`CodeFile` / :class:`ManifestFileRef`. + Falls back to ``full_path`` when the match somehow escapes the + workspace root (e.g. a symlink resolution surfaced an absolute + path on a different mount). + """ + prefix = ws_path.rstrip("/") + "/" + if full_path.startswith(prefix): + return full_path[len(prefix):] + return full_path + + @staticmethod + async def _build_code_files( + ws_path: str, + matches: List[str], + fetcher: ManifestFetcher, + *, + max_read_size: int = MAX_READ_SIZE_BYTES, + ) -> List[CodeFile]: + """Materialise a :meth:`collect` call. + + Reads each matched path with a single per-file byte cap + (``max_read_size``, defaulting to :data:`MAX_READ_SIZE_BYTES`), + sniffs the MIME type, and wraps the result in a :class:`CodeFile`. + Duplicate ``rel`` paths are skipped so callers can pass the raw + glob output without pre-deduping. + + Subclasses normally call this from their :meth:`collect` + override, supplying a ``fetcher`` that knows how to read bytes + from the underlying medium (see :data:`ManifestFetcher`). + Override this method to change the post-fetch shape (e.g. to + emit a richer ``CodeFile`` subclass) without re-implementing + the dedupe / sniff / cap loop. + """ + # Local import keeps the base-class file free of optional / + # heavy dependencies (libmagic, mimetypes lookup tables) at + # module-load time. + from .utils._files import detect_content_type + + seen: set[str] = set() + out: List[CodeFile] = [] + for full_path in matches: + rel = BaseWorkspaceFS._relativize(ws_path, full_path) + if rel in seen: + continue + seen.add(rel) + try: + data, raw_size = await fetcher(full_path, max_read_size) + except Exception: # pylint: disable=broad-except + # Keep collect() best-effort: a single unreadable file + # must not abort the whole batch. Backends that prefer + # strict semantics can short-circuit themselves before + # calling us. + out.append(CodeFile(name=rel, content="", mime_type="application/octet-stream")) + continue + mime = detect_content_type(full_path, data) + out.append( + CodeFile( + name=rel, + content=data.decode("utf-8", errors="replace"), + mime_type=mime, + size_bytes=raw_size, + truncated=raw_size > len(data), + )) + return out + + @staticmethod + async def _build_manifest_output( + ws_path: str, + spec: WorkspaceOutputSpec, + matches: List[str], + fetcher: ManifestFetcher, + ctx: Optional[InvocationContext], + *, + strict_truncated_save: bool = False, + ) -> Tuple[ManifestOutput, List[str], List[int]]: + """Materialise a :meth:`collect_outputs` call. + + Applies ``spec``'s limits (``max_files`` / ``max_file_bytes`` / + ``max_total_bytes``), fills ``inline`` / ``save`` branches, and + produces a :class:`ManifestOutput`. Also returns the list of + saved artifact names and versions so backends that record + metadata (e.g. local's ``OutputRecordMeta``) don't need to + re-scan the manifest. + + Args: + ws_path: Absolute workspace path, used to produce relative + ``name`` fields. + spec: The output spec declared by the caller. + matches: Absolute paths already filtered by the backend's + glob. + fetcher: Async callable that returns ``(data, raw_size)`` + for a path, capped by a requested byte budget. See + :data:`ManifestFetcher`. + ctx: Invocation context. Required when ``spec.save`` is set, + because artifact persistence goes through it. + strict_truncated_save: When ``True``, raise ``RuntimeError`` + if ``spec.save`` is requested for a file that was + truncated by the per-file cap. Container preserves this + "refuse to save half a binary" behaviour; local/cube + historically allow it. + + Returns: + Tuple of ``(manifest, saved_names, saved_versions)``. + """ + from .utils._files import detect_content_type + + max_files = spec.max_files or DEFAULT_MAX_FILES + max_file_bytes = spec.max_file_bytes or MAX_READ_SIZE_BYTES + max_total = spec.max_total_bytes or DEFAULT_MAX_TOTAL_BYTES + + manifest = ManifestOutput() + saved_names: List[str] = [] + saved_versions: List[int] = [] + + seen: set[str] = set() + total_bytes = 0 + count = 0 + + for full_path in matches: + # Check limits *before* fetching so a blown budget doesn't + # cause a useless read of the next big file. + if count >= max_files or total_bytes >= max_total: + manifest.limits_hit = True + break + + rel = BaseWorkspaceFS._relativize(ws_path, full_path) + if rel in seen: + continue + seen.add(rel) + + # Per-file cap is ``max_file_bytes``, but also clamp to the + # remaining total budget so a single huge file cannot exceed + # ``max_total`` all on its own. + remaining_total = max_total - total_bytes + read_budget = min(max_file_bytes, remaining_total) + if read_budget <= 0: + manifest.limits_hit = True + break + + try: + data, raw_size = await fetcher(full_path, read_budget) + except Exception: # pylint: disable=broad-except + # Mirror ``_build_code_files``: a single unreadable file + # must not abort the whole collection. Emit a sentinel + # entry with empty content and the canonical + # "unknown / unreadable" MIME type. This preserves the + # pre-refactor local behaviour + # (``_read_limited_with_cap`` caught and returned + # ``("", "application/octet-stream")``) and is a small + # tolerance upgrade for the container backend, which + # used to abort on the first transient tar error. + manifest.files.append(ManifestFileRef(name=rel, mime_type="application/octet-stream")) + count += 1 + continue + + # Mark limits_hit if either cap actually bit. + if raw_size > len(data): + manifest.limits_hit = True + + truncated = raw_size > len(data) + if truncated and spec.save and strict_truncated_save: + raise RuntimeError(f"cannot save truncated output file: {rel}") + + total_bytes += len(data) + count += 1 + + mime = detect_content_type(full_path, data) + file_ref = ManifestFileRef(name=rel, mime_type=mime) + + if spec.inline: + file_ref.content = data.decode("utf-8", errors="replace") + + if spec.save: + if ctx is None: + raise ValueError("Context is required to save artifacts") + save_name = (spec.name_template + rel) if spec.name_template else rel + version = await save_artifact_helper(ctx, save_name, data, mime) + file_ref.saved_as = save_name + file_ref.version = version + saved_names.append(save_name) + saved_versions.append(version) + + manifest.files.append(file_ref) + + return manifest, saved_names, saved_versions + + @abstractmethod + async def put_files( + self, + ws: WorkspaceInfo, + files: List[WorkspacePutFileInfo], + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Put files into workspace. + """ + pass + + @abstractmethod + async def stage_directory( + self, + ws: WorkspaceInfo, + src: str, + dst: str, + opt: WorkspaceStageOptions, + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Stage a directory into workspace. + """ + pass + + @abstractmethod + async def collect( + self, + ws: WorkspaceInfo, + patterns: List[str], + ctx: Optional[InvocationContext] = None, + ) -> List[CodeFile]: + """ + Collect files matching patterns. + """ + pass + + @abstractmethod + async def stage_inputs( + self, + ws: WorkspaceInfo, + specs: List[WorkspaceInputSpec], + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Map external inputs into workspace according to specs. + """ + pass + + @abstractmethod + async def collect_outputs( + self, + ws: WorkspaceInfo, + spec: WorkspaceOutputSpec, + ctx: Optional[InvocationContext] = None, + ) -> ManifestOutput: + """ + Apply declarative output spec to collect files. + """ + pass + + +class BaseProgramRunner(ABC): + """ + Executes programs within a workspace. + """ + + def __init__( + self, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ) -> None: + self._run_env_provider = provider + self._enable_provider_env = bool(enable_provider_env and provider) + + def _apply_provider_env( + self, + spec: WorkspaceRunProgramSpec, + ctx: Optional[InvocationContext] = None, + ) -> WorkspaceRunProgramSpec: + """Return spec with provider env merged when enabled. + + Provider values never override keys already present in ``spec.env``. + The input ``spec`` is not mutated. + """ + provider = getattr(self, "_run_env_provider", None) + if not getattr(self, "_enable_provider_env", False) or provider is None: + return spec + try: + extra = provider(ctx) or {} + except Exception as ex: # pylint: disable=broad-except + logger.warning("run env provider failed: %s", ex) + return spec + if not extra: + return spec + merged = dict(spec.env or {}) + for key, value in extra.items(): + if key not in merged: + merged[key] = value + return spec.model_copy(update={"env": merged}, deep=True) + + @abstractmethod + async def run_program( + self, + ws: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ctx: Optional[InvocationContext] = None, + ) -> WorkspaceRunResult: + """ + Run a program in workspace. + Args: + ws: WorkspaceInfo + spec: WorkspaceRunProgramSpec + ctx: Optional[InvocationContext] + Returns: + WorkspaceRunResult + """ + pass + + +class BaseWorkspaceRuntime(ABC): + """ + Base class for workspace runtime implementations. + """ + + @abstractmethod + def manager(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceManager: + """ + Get workspace manager. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseWorkspaceManager + """ + pass + + @abstractmethod + def fs(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceFS: + """ + Get workspace filesystem. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseWorkspaceFS + """ + pass + + @abstractmethod + def runner(self, ctx: Optional[InvocationContext] = None) -> BaseProgramRunner: + """ + Get program runner. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseProgramRunner + """ + pass + + @abstractmethod + def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabilities: + """ + Get engine capabilities. + Args: + ctx: Optional[InvocationContext] + Returns: + WorkspaceCapabilities + """ + pass + + +class DefaultWorkspace(BaseWorkspaceRuntime): + """ + Standard workspace implementation. + """ + + def __init__( + self, + manager: BaseWorkspaceManager, + fs: BaseWorkspaceFS, + runner: BaseProgramRunner, + ): + self._manager = manager + self._fs = fs + self._runner = runner + + def manager(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceManager: + """ + Get workspace manager. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseWorkspaceManager + """ + return self._manager + + def fs(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceFS: + """ + Get workspace filesystem. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseWorkspaceFS + """ + return self._fs + + def runner(self, ctx: Optional[InvocationContext] = None) -> BaseProgramRunner: + """ + Get program runner. + Args: + ctx: Optional[InvocationContext] + Returns: + BaseProgramRunner + """ + return self._runner + + def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabilities: + """ + Get engine capabilities. + Args: + ctx: Optional[InvocationContext] + Returns: + WorkspaceCapabilities + """ + return WorkspaceCapabilities() + + +def new_default_workspace_runtime( + manager: BaseWorkspaceManager, + fs: BaseWorkspaceFS, + runner: BaseProgramRunner, +) -> DefaultWorkspace: + """ + Construct a simple workspace from its components. + Args: + manager: BaseWorkspaceManager + fs: BaseWorkspaceFS + runner: BaseProgramRunner + Returns: + DefaultWorkspace + """ + return DefaultWorkspace(manager=manager, fs=fs, runner=runner) + + +WorkspaceRuntimeResolver: TypeAlias = Callable[[InvocationContext], BaseWorkspaceRuntime] +"""Callback to resolve a workspace runtime.""" + + +def get_workspace_runtime_with_resolver( + ctx: InvocationContext, + resolver: Optional[WorkspaceRuntimeResolver] = None, + workspace_runtime: Optional[BaseWorkspaceRuntime] = None) -> BaseWorkspaceRuntime: + """ + Get workspace runtime. + Args: + ctx: InvocationContext + resolver: WorkspaceRuntimeResolver + workspace_runtime: Optional[BaseWorkspaceRuntime] + Returns: + BaseWorkspaceRuntime + """ + if resolver is not None: + workspace_runtime = resolver(ctx) + if workspace_runtime is None: + raise ValueError("Workspace runtime not found") + return workspace_runtime diff --git a/trpc_agent_sdk/code_executors/_code_executor_context.py b/trpc_agent_sdk/code_executors/_code_executor_context.py new file mode 100644 index 000000000..1ceb6b8b5 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_code_executor_context.py @@ -0,0 +1,147 @@ +# 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. +"""Code executor context for TRPC Agent framework. + +This module provides context management for code execution, including +state tracking, file management, and error handling. +""" + +from typing import Dict +from typing import List +from typing import Optional + +from ._types import CodeBlock +from ._types import CodeExecutionResult +from ._types import CodeFile + + +class CodeExecutorContext: + """Context for managing code execution state and files.""" + + def __init__(self, session_state: Dict): + """Initialize the code executor context. + + Args: + session_state: The session state dictionary. + """ + self.session_state = session_state + self._ensure_code_execution_state() + + def _ensure_code_execution_state(self) -> None: + """Ensure code execution state exists in session.""" + if "code_execution" not in self.session_state: + self.session_state["code_execution"] = { + "input_files": [], + "processed_file_names": [], + "execution_id": None, + "error_counts": {}, + "code_execution_results": {}, + } + + def get_input_files(self) -> List[CodeFile]: + """Get input files from context. + + Returns: + List of input files. + """ + return self.session_state["code_execution"]["input_files"] + + def add_input_files(self, files: List[CodeFile]) -> None: + """Add input files to context. + + Args: + files: List of files to add. + """ + self.session_state["code_execution"]["input_files"].extend(files) + + def get_processed_file_names(self) -> List[str]: + """Get processed file names. + + Returns: + List of processed file names. + """ + return self.session_state["code_execution"]["processed_file_names"] + + def add_processed_file_names(self, file_names: List[str]) -> None: + """Add processed file names. + + Args: + file_names: List of file names to add. + """ + self.session_state["code_execution"]["processed_file_names"].extend(file_names) + + def get_execution_id(self) -> Optional[str]: + """Get execution ID. + + Returns: + Execution ID or None. + """ + return self.session_state["code_execution"]["execution_id"] + + def set_execution_id(self, execution_id: str) -> None: + """Set execution ID. + + Args: + execution_id: The execution ID to set. + """ + self.session_state["code_execution"]["execution_id"] = execution_id + + def get_error_count(self, invocation_id: str) -> int: + """Get error count for an invocation. + + Args: + invocation_id: The invocation ID. + + Returns: + Error count. + """ + return self.session_state["code_execution"]["error_counts"].get(invocation_id, 0) + + def increment_error_count(self, invocation_id: str) -> None: + """Increment error count for an invocation. + + Args: + invocation_id: The invocation ID. + """ + if invocation_id not in self.session_state["code_execution"]["error_counts"]: + self.session_state["code_execution"]["error_counts"][invocation_id] = 0 + self.session_state["code_execution"]["error_counts"][invocation_id] += 1 + + def reset_error_count(self, invocation_id: str) -> None: + """Reset error count for an invocation. + + Args: + invocation_id: The invocation ID. + """ + if invocation_id in self.session_state["code_execution"]["error_counts"]: + self.session_state["code_execution"]["error_counts"][invocation_id] = 0 + + def update_code_execution_result(self, invocation_id: str, code_blocks: List[CodeBlock], + code_execution_result: CodeExecutionResult) -> None: + """Update code execution result. + + Args: + invocation_id: The invocation ID. + code_blocks: The code blocks. + result: The code execution result. + """ + if invocation_id not in self.session_state["code_execution"]["code_execution_results"]: + self.session_state["code_execution"]["code_execution_results"][invocation_id] = [] + code = '\n'.join([code_block.code for code_block in code_blocks]) + self.session_state["code_execution"]["code_execution_results"][invocation_id].append({ + "code": + code, + "result": + code_execution_result.model_dump(), + }) + + def get_state_delta(self) -> Dict: + """Get state delta for the current execution. + + Returns: + State delta dictionary. + """ + return {"code_execution": self.session_state["code_execution"]} diff --git a/trpc_agent_sdk/code_executors/_constants.py b/trpc_agent_sdk/code_executors/_constants.py new file mode 100644 index 000000000..68857e165 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_constants.py @@ -0,0 +1,56 @@ +# 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. +"""Constants for code executors.""" + +# Default file modes and common subdirectories +DEFAULT_SCRIPT_FILE_MODE = 0o644 +DEFAULT_EXEC_FILE_MODE = 0o755 + +# Normalization constants +NORMALIZE_GLOBS_VAR_PREFIX = "$" +NORMALIZE_GLOBS_VAR_LBRACE = "${" +NORMALIZE_GLOBS_VAR_RBRACE = "}" +NORMALIZE_GLOBS_SLASH = "/" +NORMALIZE_GLOBS_BACKSLASH = "\\" +NORMALIZE_GLOBS_WORKSPACE = "WORKSPACE_DIR" +NORMALIZE_GLOBS_SKILLS = "SKILLS_DIR" +NORMALIZE_GLOBS_WORK = "WORK_DIR" +NORMALIZE_GLOBS_OUT = "OUTPUT_DIR" +NORMALIZE_GLOBS_WORKSPACE_DIR = "." +# Well-known environment keys +WORKSPACE_ENV_DIR_KEY = "WORKSPACE_DIR" + +# Well-known subdirectories in a workspace +DIR_SKILLS = "skills" +DIR_WORK = "work" +DIR_RUNS = "runs" +DIR_OUT = "out" +META_FILE_NAME = "metadata.json" +TMP_FILE_NAME = ".metadata.tmp" + +# Additional environment variable keys injected at runtime +ENV_SKILLS_DIR = "SKILLS_DIR" +ENV_WORK_DIR = "WORK_DIR" +ENV_OUTPUT_DIR = "OUTPUT_DIR" +ENV_RUN_DIR = "RUN_DIR" +ENV_SKILL_NAME = "SKILL_NAME" + +DEFAULT_TIMEOUT_SEC = 10 +DEFAULT_FILE_MODE = 0o644 +MAX_READ_SIZE_BYTES = 4 * 1024 * 1024 # 4 MiB per output file +DEFAULT_MAX_FILES = 100 +DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024 # 64 MiB total + +# Constants +DEFAULT_CREATE_TIMEOUT_SEC = 5 +DEFAULT_RM_TIMEOUT_SEC = 10 +DEFAULT_STAGE_TIMEOUT_SEC = 10 +DEFAULT_RUN_CONTAINER_BASE = "/tmp/run" +DEFAULT_SKILLS_CONTAINER = "/opt/trpc-agent/skills" +DEFAULT_INPUTS_CONTAINER = "/opt/trpc-agent/inputs" + +# Directory constants +INLINE_SOURCE_DIR = "src" diff --git a/trpc_agent_sdk/code_executors/_program_session.py b/trpc_agent_sdk/code_executors/_program_session.py new file mode 100644 index 000000000..177a00a88 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_program_session.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# +# Copyright @ 2026 Tencent.com +"""Program-session helpers. +""" + +from __future__ import annotations + +import asyncio +import time +from abc import ABC +from abc import abstractmethod +from dataclasses import dataclass +from typing import Optional + +from ._types import WorkspaceRunResult + +# Default wait windows (milliseconds). +DEFAULT_EXEC_YIELD_MS = 1_000 +DEFAULT_IO_YIELD_MS = 400 +DEFAULT_POLL_LINES = 40 + +# Poll pacing / settle windows (seconds). +DEFAULT_POLL_WAIT_SEC = 0.05 +DEFAULT_POLL_SETTLE_SEC = 0.075 + +# Session lifecycle defaults (seconds). +DEFAULT_SESSION_TTL_SEC = 30 * 60 +DEFAULT_SESSION_KILL_SEC = 2.0 + +PROGRAM_STATUS_RUNNING = "running" +PROGRAM_STATUS_EXITED = "exited" + + +@dataclass +class ProgramPoll: + """Incremental output chunk for a running or exited session.""" + + status: str = PROGRAM_STATUS_RUNNING + output: str = "" + offset: int = 0 + next_offset: int = 0 + exit_code: Optional[int] = None + + +@dataclass +class ProgramLog: + """Non-destructive output window from a specific offset.""" + + output: str = "" + offset: int = 0 + next_offset: int = 0 + + +@dataclass +class ProgramState: + """Non-streaming session status without cursor mutation.""" + + status: str = PROGRAM_STATUS_RUNNING + exit_code: Optional[int] = None + + +class BaseProgramSession(ABC): + """Base class for program sessions.""" + + @abstractmethod + def id(self) -> str: + """Return stable session id.""" + + @abstractmethod + async def poll(self, limit: Optional[int] = None) -> ProgramPoll: + """Advance cursor and return incremental output.""" + + @abstractmethod + async def log(self, offset: Optional[int] = None, limit: Optional[int] = None) -> ProgramLog: + """Read output from offset without advancing cursor.""" + + @abstractmethod + async def write(self, data: str, newline: bool) -> None: + """Write input to session.""" + + @abstractmethod + async def kill(self, grace_seconds: float) -> None: + """Terminate session, escalating after grace period.""" + + @abstractmethod + async def close(self) -> None: + """Release resources and stop background routines.""" + + @abstractmethod + async def state(self) -> ProgramState: + """Return current state snapshot.""" + + @abstractmethod + async def run_result(self) -> WorkspaceRunResult: + """Return final run result after session exits.""" + + +def yield_duration_ms(ms: int, fallback_ms: int) -> float: + """Normalize milliseconds into seconds with fallback and clamping.""" + if ms < 0: + ms = 0 + if ms == 0: + ms = fallback_ms + return ms / 1000.0 + + +def poll_line_limit(lines: int) -> int: + """Return a positive poll-line limit with default fallback.""" + if lines <= 0: + lines = DEFAULT_POLL_LINES + return lines + + +async def wait_for_program_output( + proc: BaseProgramSession, + yield_seconds: float, + limit: Optional[int], +) -> ProgramPoll: + """Poll until session exits or output settles within the yield window.""" + deadline = time.monotonic() + if yield_seconds > 0: + deadline += yield_seconds + + out_parts: list[str] = [] + offset = 0 + next_offset = 0 + have_chunk = False + settle_deadline = 0.0 + + while True: + poll = await proc.poll(limit) + if poll.output: + if not have_chunk: + offset = poll.offset + have_chunk = True + out_parts.append(poll.output) + next_offset = poll.next_offset + settle_deadline = time.monotonic() + DEFAULT_POLL_SETTLE_SEC + if yield_seconds <= 0: + deadline = settle_deadline + elif not have_chunk: + offset = poll.offset + next_offset = poll.next_offset + else: + next_offset = poll.next_offset + + if poll.status == PROGRAM_STATUS_EXITED: + poll.output = "".join(out_parts) + poll.offset = offset + poll.next_offset = next_offset + return poll + + now = time.monotonic() + if settle_deadline and now > settle_deadline: + poll.output = "".join(out_parts) + poll.offset = offset + poll.next_offset = next_offset + return poll + + if yield_seconds > 0 and now > deadline: + poll.output = "".join(out_parts) + poll.offset = offset + poll.next_offset = next_offset + return poll + + await asyncio.sleep(DEFAULT_POLL_WAIT_SEC) diff --git a/trpc_agent_sdk/code_executors/_types.py b/trpc_agent_sdk/code_executors/_types.py new file mode 100644 index 000000000..b1e2648a6 --- /dev/null +++ b/trpc_agent_sdk/code_executors/_types.py @@ -0,0 +1,333 @@ +# 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. +"""Types for code executors.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field +from trpc_agent_sdk.types import CodeExecutionResult +from trpc_agent_sdk.types import Outcome + + +class CodeFile(BaseModel): + """Code file structure for code execution.""" + + name: str + """File name.""" + + content: str + """File content.""" + + mime_type: str + """MIME type of the file.""" + + size_bytes: int = 0 + """File size in bytes.""" + + truncated: bool = False + """Whether the file is truncated.""" + + +class CodeBlock(BaseModel): + """Represents a single block of code to be executed. + + Attributes: + code: The code content to execute + language: Programming language (python, bash, etc.) + """ + language: str = "" + """ programming language (python, bash, etc.)""" + + code: str = "" + """ code content to execute""" + + +class CodeBlockDelimiter(BaseModel): + """Defines the start and end delimiters for code blocks. + + Attributes: + start: Start delimiter (e.g., "```") + end: End delimiter (e.g., "```") + """ + start: str = "```" + """ start delimiter (e.g., "```")""" + + end: str = "```" + """ end delimiter (e.g., "```")""" + + +class CodeExecutionInput(BaseModel): + """Input for code execution.""" + + code_blocks: list[CodeBlock] = Field(default_factory=list) + """ list of code blocks to execute""" + + code: str = "" + """ code to execute""" + + input_files: list[CodeFile] = Field(default_factory=list) + """ list of input files for the code execution""" + + execution_id: Optional[str] = None + """ execution id for stateful code execution""" + + +class WorkspaceInfo(BaseModel): + """ + Represents an isolated execution workspace. + + Path is a workspace path. + """ + + id: str = "" + """ workspace id""" + + path: str = "" + """ workspace path""" + + +class WorkspacePutFileInfo(BaseModel): + """ + Describes a file to place into a workspace. + """ + + path: str = "" + """ file path""" + + content: bytes = b"" + """ file content""" + + mode: int = 0 + """ file mode""" + + +class WorkspaceResourceLimits(BaseModel): + """ + Restricts program execution resources. + """ + + cpu_percent: int = 0 + """ cpu percent""" + + memory_mb: int = 0 + """ memory in mb""" + + max_pids: int = 0 + """ maximum number of pids""" + + +class WorkspaceRunProgramSpec(BaseModel): + """ + Describes a program invocation in a workspace. + """ + + cmd: str = "" + """ command to execute""" + + args: list[str] = Field(default_factory=list) + """ list of arguments to execute""" + + env: dict[str, str] = Field(default_factory=dict) + """ environment variables to execute""" + + cwd: str = "" + """ current working directory""" + + stdin: str = "" + """ stdin to execute""" + + timeout: float = 0 + """ timeout in seconds""" + + limits: WorkspaceResourceLimits = Field(default_factory=WorkspaceResourceLimits) + """ resource limits""" + + tty: bool = Field(default=False, description="Allocate pseudo-TTY") + """ whether to allocate pseudo-TTY""" + + +class WorkspaceRunResult(BaseModel): + """ + Captures a single program run result. + """ + + stdout: str = "" + """ standard output""" + + stderr: str = "" + """ standard error""" + + exit_code: int = 0 + """ exit code""" + + duration: float = 0 + """ duration in seconds""" + + timed_out: bool = False + """ whether timed out""" + + +class WorkspaceStageOptions(BaseModel): + """ + Controls directory staging behavior. + """ + + read_only: bool = False + """ whether to read only the mount""" + + allow_mount: bool = False + """ whether to allow mount""" + + mode: str = "link" + """ staging mode hint, e.g. 'copy' or 'link'. Default is 'link'.""" + + +class WorkspaceCapabilities(BaseModel): + """ + Describes workspace capabilities for selection. + """ + + isolation: str = "" + """ isolation type""" + + network_allowed: bool = False + """ whether to allow network""" + + read_only_mount: bool = False + """ whether to read only the mount""" + + streaming: bool = False + """ whether to allow streaming""" + + max_disk_bytes: int = 0 + """ maximum disk space to use""" + + +class WorkspaceInputSpec(BaseModel): + """ + Declares a single input mapping into the workspace. + + From supports schemes: + - artifact://name[@version] + - host://abs/path + - workspace://rel/path + - skill://name/rel/path + + To is a workspace-relative destination (default: WORK_DIR/inputs/). + Mode hints the strategy: "link" (symlink/hardlink where possible) or + "copy" (default fallback when link is not possible). + """ + + src: str = "" + """ source path""" + + dst: str = "" + """ destination path""" + + mode: str = "" + """ mode to use for the input""" + + pin: bool = False + """ whether to pin the input""" + + +class WorkspaceOutputSpec(BaseModel): + """ + Declares outputs to collect and optionally persist. + + Globs are workspace-relative patterns; implementations should + support ** semantics. + """ + + globs: list[str] = Field(default_factory=list) + """ list of glob patterns to collect""" + + max_files: int = 0 + """ maximum number of files to collect""" + + max_file_bytes: int = 0 + """ maximum file size to collect""" + + max_total_bytes: int = 0 + """ maximum total size to collect""" + + save: bool = False + """ whether to save the output""" + + name_template: str = "" + """ name template for the output""" + + inline: bool = False + """ whether to inline the output""" + + +class ManifestFileRef(BaseModel): + """ + References a file collected from workspace. + """ + + name: str = "" + """ file name""" + + mime_type: str = "" + """ mime type""" + + content: str = "" + """ content""" + + saved_as: str = "" + """ saved as""" + + version: int = 0 + """ version""" + + +class ManifestOutput(BaseModel): + """ + The structured result of CollectOutputs. + """ + + files: list[ManifestFileRef] = Field(default_factory=list) + """ list of files""" + + limits_hit: bool = False + """ whether limits hit""" + + +def create_code_execution_result(stdout: str = '', + stderr: str = '', + output_files: Optional[list[CodeFile]] = None, + is_timed_out: bool = False) -> CodeExecutionResult: + """Create a code execution result. + + Args: + stdout: The standard output of the code execution. + stderr: The standard error of the code execution. + output_files: The output files of the code execution. + is_timed_out: Whether the code execution timed out. + + Returns: + The code execution result. + """ + if output_files is None: + output_files = [] + out_str = '' + outcome = Outcome.OUTCOME_OK + if stderr: + out_str = f"Code execution error:\n{stderr}\n" + outcome = Outcome.OUTCOME_FAILED + if is_timed_out: + out_str += "Code execution timed out\n" + outcome = Outcome.OUTCOME_DEADLINE_EXCEEDED + if stdout: + out_str += f"Code execution result:\n{stdout}\n" + if output_files: + out_str += "Saved artifacts:\n" + ",".join([f"`{f.name}`" for f in output_files]) + + return CodeExecutionResult(outcome=outcome, output=out_str) diff --git a/trpc_agent_sdk/code_executors/container/__init__.py b/trpc_agent_sdk/code_executors/container/__init__.py new file mode 100644 index 000000000..a29821e07 --- /dev/null +++ b/trpc_agent_sdk/code_executors/container/__init__.py @@ -0,0 +1,33 @@ +# 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. +"""Container code executors for TRPC Agent framework. + +This module provides container code executor implementations, including ContainerCodeExecutor. +""" + +from ._container_cli import CommandArgs +from ._container_cli import ContainerClient +from ._container_cli import ContainerConfig +from ._container_code_executor import ContainerCodeExecutor +from ._container_ws_runtime import ContainerProgramRunner +from ._container_ws_runtime import ContainerWorkspaceFS +from ._container_ws_runtime import ContainerWorkspaceManager +from ._container_ws_runtime import ContainerWorkspaceRuntime +from ._container_ws_runtime import RuntimeConfig +from ._container_ws_runtime import create_container_workspace_runtime + +__all__ = [ + "CommandArgs", + "ContainerClient", + "ContainerConfig", + "ContainerCodeExecutor", + "ContainerProgramRunner", + "ContainerWorkspaceFS", + "ContainerWorkspaceManager", + "ContainerWorkspaceRuntime", + "RuntimeConfig", + "create_container_workspace_runtime", +] diff --git a/trpc_agent_sdk/code_executors/container/_container_cli.py b/trpc_agent_sdk/code_executors/container/_container_cli.py new file mode 100644 index 000000000..39447ad6e --- /dev/null +++ b/trpc_agent_sdk/code_executors/container/_container_cli.py @@ -0,0 +1,312 @@ +# -*- coding: utf-8 -*- +# +# Copyright @ 2025 Tencent.com +"""Container code executor for TRPC Agent framework. + +This module provides a code executor that uses a custom container to execute code. +This executor provides better isolation and security compared to unsafe local execution. +""" + +from __future__ import annotations + +import asyncio +import atexit +import os +import socket as pysocket +from dataclasses import dataclass +from typing import Optional + +import docker +from docker.models.containers import Container +from docker.utils.socket import consume_socket_output +from docker.utils.socket import demux_adaptor +from docker.utils.socket import frames_iter +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.utils import CommandExecResult + +DEFAULT_IMAGE_TAG = 'python:3-slim' + + +@dataclass +class ContainerConfig: + """Configuration for container.""" + base_url: Optional[str] = None + """The base url of the user hosted Docker client.""" + image: str = DEFAULT_IMAGE_TAG + """The tag of the predefined image or custom image to run on the container. + Either docker_path or image must be set. + """ + docker_path: Optional[str] = None + """The path to the Docker file to build the image from.""" + host_config: Optional[dict] = None + """Optional host config (for example {"Binds": ["/host:/container:ro"]}).""" + + +@dataclass +class CommandArgs: + """Command arguments.""" + environment: Optional[dict[str, str]] = None + """The environment variables for the command execution.""" + timeout: Optional[float] = None + """The timeout for the command execution in seconds.""" + stdin: Optional[str] = None + """Optional stdin content to write once before reading output.""" + + +class ContainerClient: + """Container CLI client class.""" + + def __init__(self, config: ContainerConfig): + """Initialize the container.""" + self.base_url = config.base_url + self.image = config.image + self.docker_path = os.path.abspath(config.docker_path) if config.docker_path else None + self.host_config = config.host_config or {} + self._client = None + self._container = None + self._init_docker_client() + self._init_container() + atexit.register(self._cleanup_container) + + @property + def client(self) -> docker.DockerClient: + """Get the Docker client.""" + return self._client + + @property + def container(self) -> Container: + """Get the container.""" + return self._container + + def _init_docker_client(self): + """Initialize the Docker client with comprehensive error handling. + + This method attempts to connect to Docker using docker SDK's from_env() + which handles various Docker connection methods including: + - Standard Unix socket (/var/run/docker.sock) + - Docker Desktop (Windows/Mac) + - Remote Docker via DOCKER_HOST environment variable + - Custom base_url if provided + """ + # Try to initialize Docker client + # Let docker SDK handle connection detection (it supports various methods) + try: + if self.base_url: + # Use custom base_url if provided + self._client = docker.DockerClient(base_url=self.base_url) + else: + # Use docker.from_env() which automatically detects: + # - DOCKER_HOST environment variable + # - Standard socket paths + # - Docker Desktop configurations + self._client = docker.from_env() + + # Test connection by pinging Docker daemon + # This will fail if Docker is not running or not accessible + self._client.ping() + logger.info("Docker client initialized successfully") + except docker.errors.DockerException as ex: + # Extract more specific error information + error_str = str(ex) + + # Check if it's a connection error + if "Connection" in error_str or "socket" in error_str.lower() or "No such file" in error_str: + error_msg = ("Failed to connect to Docker daemon. Docker may not be running or accessible.\n\n" + "Common solutions:\n" + " 1. Start Docker daemon:\n" + " - Linux: sudo systemctl start docker\n" + " - Windows/Mac: Start Docker Desktop application\n" + " 2. Verify Docker is running: docker ps\n" + " 3. Check Docker socket permissions (Linux):\n" + " - sudo chmod 666 /var/run/docker.sock\n" + " - Or add your user to docker group: sudo usermod -aG docker $USER\n" + " 4. For Docker Desktop, ensure it's fully started (check system tray)\n" + " 5. Check DOCKER_HOST environment variable if using remote Docker\n" + " 6. If using remote Docker, set base_url parameter in ContainerCodeExecutor\n\n" + f"Original error: {error_str}") + else: + error_msg = (f"Failed to connect to Docker daemon: {error_str}\n\n" + "Please ensure:\n" + " 1. Docker daemon is running: docker ps\n" + " 2. You have permission to access Docker\n" + " 3. Docker is properly installed and configured") + raise RuntimeError(error_msg) from ex + except Exception as ex: # pylint: disable=broad-except + error_msg = (f"Unexpected error initializing Docker client: {str(ex)}\n\n" + "Please check:\n" + " 1. Docker installation: docker --version\n" + " 2. Docker daemon status: docker ps\n" + " 3. Docker SDK installation: pip show docker") + raise RuntimeError(error_msg) from ex + + def _init_container(self): + """Initialize the container.""" + if not self._client: + raise RuntimeError("Docker client is not initialized.") + + if self.docker_path: + self._build_docker_image() + + logger.info("Starting container for ContainerCodeExecutor...") + run_kwargs = {} + binds = self.host_config.get("Binds") + if binds: + # docker SDK `run` supports bind specs via `volumes`. + run_kwargs["volumes"] = binds + logger.info("Container bind mounts enabled: %s", binds) + command = self.host_config.get("command", ["tail", "-f", "/dev/null"]) + stdin = self.host_config.get("stdin", True) + working_dir = self.host_config.get("working_dir", "/") + network_mode = self.host_config.get("network_mode", "none") + auto_remove = self.host_config.get("auto_remove", True) + run_kwargs.setdefault("command", command) + run_kwargs.setdefault("stdin_open", stdin) + run_kwargs.setdefault("working_dir", working_dir) + run_kwargs.setdefault("network_mode", network_mode) + run_kwargs.setdefault("auto_remove", auto_remove) + self._container = self._client.containers.run( + image=self.image, + detach=True, + tty=True, + **run_kwargs, + ) + logger.info("Container %s started.", self._container.id) + + # Verify the container is able to run python3. + self._verify_python_installation() + + def _build_docker_image(self): + """Build the Docker image.""" + if not self.docker_path: + raise ValueError("Docker path is not set.") + if not os.path.exists(self.docker_path): + raise FileNotFoundError(f"Invalid Docker path: {self.docker_path}") + + logger.info("Building Docker image...") + self._client.images.build( + path=self.docker_path, + tag=self.image, + rm=True, + ) + logger.info("Docker image: %s built.", self.image) + + def _verify_python_installation(self): + """Verify the container has python3 installed.""" + exec_result = self._container.exec_run(["which", "python3"]) + if exec_result.exit_code != 0: + raise ValueError("python3 is not installed in the container.") + + def _cleanup_container(self): + """Close the container on exit.""" + if not self._container: + return + + logger.info("[Cleanup] Stopping the container...") + try: + self._container.stop() + except Exception: # pylint: disable=broad-except + pass + try: + self._container.remove() + except Exception: # pylint: disable=broad-except + pass + logger.info("Container %s stopped and removed.", self._container.id) + # self._container = None + + def _exec_run_with_stdin( + self, + cmd: list[str], + environment: dict[str, str], + stdin: str, + ) -> CommandExecResult: + """Execute command with attached stdin, similar to docker exec attach.""" + resp = self.container.client.api.exec_create( + self.container.container.id, + cmd=cmd[:], + stdout=True, + stderr=True, + stdin=True, + tty=False, + environment=environment, + ) + exec_id = resp["Id"] + sock = self.container.client.api.exec_start( + exec_id, + detach=False, + tty=False, + stream=False, + socket=True, + demux=False, + ) + try: + data = (stdin or "").encode("utf-8") + if data: + try: + sock.sendall(data) + except Exception: # pylint: disable=broad-except + # Some transports expose the real socket as _sock. + sock._sock.sendall(data) # pylint: disable=protected-access + + try: + sock.shutdown(pysocket.SHUT_WR) + except Exception: # pylint: disable=broad-except + close_write = getattr(sock, "close_write", None) + if callable(close_write): + close_write() + + frames = frames_iter(sock, tty=False) + demux_frames = (demux_adaptor(*frame) for frame in frames) + output = consume_socket_output(demux_frames, demux=True) + stdout = output[0].decode("utf-8") if output and output[0] else "" + stderr = output[1].decode("utf-8") if output and output[1] else "" + finally: + try: + sock.close() + except Exception: # pylint: disable=broad-except + pass + + inspect = self.container.client.api.exec_inspect(exec_id) + exit_code = int(inspect.get("ExitCode", -1)) + return CommandExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code, is_timeout=False) + + async def exec_run(self, cmd: list[str], command_args: CommandArgs) -> CommandExecResult: + """Execute command in container.""" + timeout = command_args.timeout + try: + loop = asyncio.get_event_loop() + if command_args.stdin: + co = loop.run_in_executor( + None, + lambda: self._exec_run_with_stdin( + cmd, + command_args.environment or {}, + command_args.stdin or "", + ), + ) + else: + co = loop.run_in_executor( + None, + lambda: self.container.exec_run(cmd=cmd[:], demux=True, environment=command_args.environment or {})) + if command_args.timeout: + result = await asyncio.wait_for(co, timeout=command_args.timeout) + else: + result = await co + + if command_args.stdin: + return result + + exit_code, output = result + stdout = output[0].decode('utf-8') if output[0] else "" + stderr = output[1].decode('utf-8') if output[1] else "" + except asyncio.TimeoutError: + return CommandExecResult(stdout="", + stderr=f"Command timed out after {timeout}s in `{' '.join(cmd)}`\n", + exit_code=-1, + is_timeout=True) + except Exception as ex: # pylint: disable=broad-except + return CommandExecResult(stdout="", + stderr=f"Execution error: {str(ex)} in `{' '.join(cmd)}`\n", + exit_code=-1, + is_timeout=False) + else: + return CommandExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code, is_timeout=False) diff --git a/trpc_agent_sdk/code_executors/container/_container_code_executor.py b/trpc_agent_sdk/code_executors/container/_container_code_executor.py new file mode 100644 index 000000000..85113bd6b --- /dev/null +++ b/trpc_agent_sdk/code_executors/container/_container_code_executor.py @@ -0,0 +1,150 @@ +# 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. +"""Container code executor for TRPC Agent framework. + +This module provides a code executor that uses a custom container to execute code. +This executor provides better isolation and security compared to unsafe local execution. +""" + +from __future__ import annotations + +from typing import Optional +from typing_extensions import override + +from pydantic import Field +from trpc_agent_sdk.context import InvocationContext + +from .._base_code_executor import BaseCodeExecutor +from .._types import CodeExecutionInput +from .._types import CodeExecutionResult +from .._types import create_code_execution_result +from ._container_cli import CommandArgs +from ._container_cli import ContainerClient +from ._container_cli import ContainerConfig + + +class ContainerCodeExecutor(BaseCodeExecutor): + """A code executor that uses a custom container to execute code. + + Attributes: + base_url: Optional. The base url of the user hosted Docker client. + image: The tag of the predefined image or custom image to run on the + container. Either docker_path or image must be set. + docker_path: The path to the directory containing the Dockerfile. If set, + build the image from the dockerfile path instead of using the predefined + image. Either docker_path or image must be set. + """ + + base_url: Optional[str] = None + """Optional. The base url of the user hosted Docker client.""" + + image: Optional[str] = None + """The tag of the predefined image or custom image to run on the container. + Either docker_path or image must be set. + """ + + docker_path: Optional[str] = None + """The path to the directory containing the Dockerfile. + If set, build the image from the dockerfile path instead of using the + predefined image. Either docker_path or image must be set. + """ + timeout: Optional[float] = None + """The timeout for the code execution in seconds.""" + + environment: Optional[dict[str, str]] = None + """The environment variables to set for the code execution.""" + + # Overrides the BaseCodeExecutor attribute: this executor cannot be stateful. + stateful: bool = Field(default=False, frozen=True, exclude=True) + + # Overrides the BaseCodeExecutor attribute: this executor cannot optimize_data_file. + optimize_data_file: bool = Field(default=False, frozen=True, exclude=True) + + _container: ContainerClient = None + """The container instance.""" + + def __init__( + self, + base_url: Optional[str] = None, + image: Optional[str] = None, + docker_path: Optional[str] = None, + **data, + ): + """Initialize the ContainerCodeExecutor. + + Args: + base_url: Optional. The base url of the user hosted Docker client. + image: The tag of the predefined image or custom image to run on the + container. Either docker_path or image must be set. + docker_path: The path to the directory containing the Dockerfile. If set, + build the image from the dockerfile path instead of using the predefined + image. Either docker_path or image must be set. + **data: The data to initialize the ContainerCodeExecutor. + """ + if not image and not docker_path: + raise ValueError('Either image or docker_path must be set for ContainerCodeExecutor.') + if 'stateful' in data and data['stateful']: + raise ValueError('Cannot set `stateful=True` in ContainerCodeExecutor.') + if 'optimize_data_file' in data and data['optimize_data_file']: + raise ValueError('Cannot set `optimize_data_file=True` in ContainerCodeExecutor.') + + super().__init__(**data) + self.base_url = base_url + self.image = image + self.docker_path = docker_path + if not self._container: + self._container = ContainerClient( + config=ContainerConfig(base_url=base_url, image=image, docker_path=docker_path)) + if not self._container: + raise Exception("Container not initialized") + + @override + async def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + """Execute code in the container. + + Args: + invocation_context: The invocation context of the code execution. + code_execution_input: The code execution input. + + Returns: + The code execution response. + """ + all_output = [] + all_errors = [] + + # Execute each code block + for block in code_execution_input.code_blocks: + exec_cmd = [] + + # Determine command based on language + language = block.language.lower() if block.language else "" + + if language in ["bash", "sh"]: + exec_cmd = ["/bin/bash", "-c", block.code] + elif language in ["python", "py", "python3", ""]: + # Default to python if no language specified + exec_cmd = ["python3", "-c", block.code] + else: + # Unsupported language + error_msg = f"unsupported language: {block.language}\n" + all_errors.append(error_msg) + continue + command_args = CommandArgs(environment=self.environment, timeout=self.timeout) + output = await self._container.exec_run(cmd=exec_cmd, command_args=command_args) + if output.exit_code: + all_errors.append(output.stderr) + continue + else: + all_output.append(output.stdout) + + # Combine stdout and stderr + output = "".join(all_output) + err_str = "".join(all_errors) + return create_code_execution_result(stdout=output, stderr=err_str) diff --git a/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py b/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py new file mode 100644 index 000000000..29a16b74a --- /dev/null +++ b/trpc_agent_sdk/code_executors/container/_container_ws_runtime.py @@ -0,0 +1,953 @@ +# 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. +""" +Tencent is pleased to support the open source community by making +trpc-agent-go available. + +Copyright (C) 2025 Tencent. All rights reserved. + +trpc-agent-go is licensed under the Apache License Version 2.0. + +Container workspace runtime implementation for Docker-based code execution. +""" + +import io +import json +import os +import tarfile +import time +from dataclasses import dataclass +from dataclasses import field +from datetime import datetime +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger + +from .._artifacts import load_artifact_helper +from .._artifacts import parse_artifact_ref +from .._base_workspace_runtime import BaseProgramRunner +from .._base_workspace_runtime import BaseWorkspaceFS +from .._base_workspace_runtime import BaseWorkspaceManager +from .._base_workspace_runtime import BaseWorkspaceRuntime +from .._base_workspace_runtime import RunEnvProvider +from .._constants import DEFAULT_INPUTS_CONTAINER +from .._constants import DEFAULT_RUN_CONTAINER_BASE +from .._constants import DEFAULT_SKILLS_CONTAINER +from .._constants import DEFAULT_TIMEOUT_SEC +from .._constants import DIR_OUT +from .._constants import DIR_RUNS +from .._constants import DIR_SKILLS +from .._constants import DIR_WORK +from .._constants import ENV_OUTPUT_DIR +from .._constants import ENV_RUN_DIR +from .._constants import ENV_SKILLS_DIR +from .._constants import ENV_WORK_DIR +from .._constants import MAX_READ_SIZE_BYTES +from .._constants import META_FILE_NAME +from .._constants import WORKSPACE_ENV_DIR_KEY +from .._types import CodeFile +from .._types import ManifestOutput +from .._types import WorkspaceCapabilities +from .._types import WorkspaceInfo +from .._types import WorkspaceInputSpec +from .._types import WorkspaceOutputSpec +from .._types import WorkspacePutFileInfo +from .._types import WorkspaceRunProgramSpec +from .._types import WorkspaceRunResult +from .._types import WorkspaceStageOptions +from ..utils import InputRecordMeta +from ..utils import WorkspaceMetadata +from ..utils import get_rel_path +from ..utils import normalize_globs +from ._container_cli import CommandArgs +from ._container_cli import ContainerClient +from ._container_cli import ContainerConfig + + +@dataclass +class RuntimeConfig: + """ + Configuration for container runtime. + """ + skills_host_base: str = "" + skills_container_base: str = DEFAULT_SKILLS_CONTAINER + run_container_base: str = DEFAULT_RUN_CONTAINER_BASE + inputs_host_base: str = "" + inputs_container_base: str = DEFAULT_INPUTS_CONTAINER + auto_map_inputs: bool = True + command_args: CommandArgs = field(default_factory=CommandArgs) + + +def _shell_quote(s: str) -> str: + if not s: + return "''" + return "'" + s.replace("'", "'\\''") + "'" + + +class ContainerWorkspaceManager(BaseWorkspaceManager): + """ + Docker container-based workspace manager implementation. + """ + + def __init__(self, container: ContainerClient, config: RuntimeConfig, fs: BaseWorkspaceFS): + """ + Initialize container workspace manager. + + Args: + client: Docker client instance + container: Docker container to use + config: Runtime configuration + """ + self.container = container + self.config = config + self.fs = fs + self.ws_paths: dict[str, WorkspaceInfo] = {} + + @override + async def create_workspace(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> WorkspaceInfo: + """ + Create a new workspace inside the container. + + Args: + exec_id: Unique execution identifier + ctx: Optional[InvocationContext] + + Returns: + Created workspace instance + + Raises: + RuntimeError: If container is not ready or creation fails + """ + if exec_id in self.ws_paths: + return self.ws_paths[exec_id] + safe_id = self._sanitize(exec_id) + suffix = time.time_ns() + + ws_path = str(Path(self.config.run_container_base) / f"ws_{safe_id}_{suffix}") + + # Create standard directory layout + cmd_str = ("set -e; " + f"mkdir -p {_shell_quote(ws_path)} " + f"{_shell_quote(str(Path(ws_path) / DIR_SKILLS))} " + f"{_shell_quote(str(Path(ws_path) / DIR_WORK))} " + f"{_shell_quote(str(Path(ws_path) / DIR_RUNS))} " + f"{_shell_quote(str(Path(ws_path) / DIR_OUT))}; " + f"[ -f {_shell_quote(str(Path(ws_path) / META_FILE_NAME))} ] || " + f"echo '{{}}' > {_shell_quote(str(Path(ws_path) / META_FILE_NAME))}") + cmd = ["/bin/bash", "-lc", cmd_str] + + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0: + raise RuntimeError(f"Failed to create workspace: {result.stderr}") + + ws = WorkspaceInfo(id=exec_id, path=ws_path) + + # Auto-map inputs if configured + if self.config.auto_map_inputs and self.config.inputs_host_base: + logger.info("Auto-mapping inputs for workspace %s", exec_id) + specs = [ + WorkspaceInputSpec(src=f"host://{self.config.inputs_host_base}", + dst=str(Path("work") / "inputs"), + mode="link") + ] + await self.fs.stage_inputs(ws, specs, ctx) + + self.ws_paths[exec_id] = ws + return ws + + @override + async def cleanup(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> None: + """ + Remove workspace directory from container. + + Args: + exec_id: Execution ID + ctx: Optional[InvocationContext] + """ + ws = self.ws_paths.get(exec_id) + if not ws or not ws.path: + return + + cmd = ["/bin/bash", "-lc", f"rm -rf '{ws.path}'"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0: + raise RuntimeError(f"Failed to clean up workspace: {result.stderr}") + logger.info("Cleaned up workspace: %s", ws.path) + self.ws_paths.pop(exec_id, None) + + @staticmethod + def _sanitize(s: str) -> str: + """ + Sanitize string for use in file paths. + + Args: + s: String to sanitize + + Returns: + Sanitized string + """ + result = [] + for c in s: + if c.isalnum() or c in ['-', '_']: + result.append(c) + else: + result.append('_') + return ''.join(result) + + +class ContainerWorkspaceFS(BaseWorkspaceFS): + """ + Docker container-based workspace filesystem implementation. + """ + + def __init__(self, container: ContainerClient, config: RuntimeConfig): + """ + Initialize container workspace filesystem. + + Args: + client: Docker client instance + container: Docker container to use + config: Runtime configuration + """ + self.container = container + self.config = config + + @override + async def put_files(self, + ws: WorkspaceInfo, + files: List[WorkspacePutFileInfo], + ctx: Optional[InvocationContext] = None) -> None: + """ + Write files into workspace via tar archive. + + Args: + ws: Target workspace + files: List of files to write + ctx: Optional[InvocationContext] + + Raises: + RuntimeError: If file writing fails + """ + if not files: + return + + tar_stream = self._create_tar_from_files(files) + success = self.container.client.api.put_archive(self.container.container.id, ws.path, tar_stream) + + if not success: + raise RuntimeError("Failed to put files into container") + + logger.info("Put %s files into workspace %s", len(files), ws.path) + + @override + async def stage_directory(self, + ws: WorkspaceInfo, + src: str, + dst: str, + opt: WorkspaceStageOptions, + ctx: Optional[InvocationContext] = None) -> None: + """ + Stage a directory into workspace. + + Args: + ws: Target workspace + src: Source directory path + dst: Destination path in workspace + opt: Staging options + ctx: Optional[InvocationContext] + + Raises: + RuntimeError: If staging fails + """ + src_abs_path = os.path.abspath(src) + container_dst = str(Path(ws.path) / dst) if dst else ws.path + # Fast path: within skills mount + if opt.allow_mount and self.config.skills_host_base: + rel_path = get_rel_path(self.config.skills_host_base, src_abs_path) + if rel_path: + container_src = str(Path(self.config.skills_container_base) / rel_path) + cmd_str = f"mkdir -p '{container_dst}' && cp -a '{container_src}/.' '{container_dst}'" + if opt.read_only: + cmd_str += f" && chmod -R a-w '{container_dst}'" + + cmd = ["/bin/bash", "-lc", cmd_str] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0: + raise RuntimeError(f"Failed to stage directory: {result.stderr}") + return + + # Fallback: tar copy + await self._put_directory(ws, src_abs_path, dst) + + if opt.read_only: + cmd = ["/bin/bash", "-lc", f"chmod -R a-w '{container_dst}'"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0: + raise RuntimeError(f"Failed to chmod directory: {result.stderr}") + + @override + async def collect(self, + ws: WorkspaceInfo, + patterns: List[str], + ctx: Optional[InvocationContext] = None) -> List[CodeFile]: + """ + Collect files matching patterns from workspace. + + Args: + ws: Source workspace + patterns: Glob patterns to match + ctx: Optional[InvocationContext] + + Returns: + List of collected files + + Raises: + RuntimeError: If collection fails + """ + matches = await self._enumerate_matches( + ws, + self._normalize_globs(patterns), + resolve_symlinks=True, + error_prefix="Failed to collect files", + ) + files = await self._build_code_files(ws.path, matches, self._fetch_bytes) + logger.info("Collected %s files from workspace", len(files)) + return files + + @override + async def stage_inputs(self, + ws: WorkspaceInfo, + specs: List[WorkspaceInputSpec], + ctx: Optional[InvocationContext] = None) -> None: + """ + Stage inputs into workspace according to specifications. + + Args: + ws: Target workspace + specs: Input staging specifications + ctx: Optional[InvocationContext] + + Raises: + RuntimeError: If staging fails + """ + md = await self._load_workspace_metadata(ws) + for spec in specs: + mode = (spec.mode or "").lower().strip() or "copy" + dst_rel = (spec.dst or "").strip() or str(Path(DIR_WORK) / "inputs" / self._input_base(spec.src)) + dst_abs = str(Path(ws.path) / dst_rel) + + resolved = "" + version: Optional[int] = None + + if spec.src.startswith("artifact://"): + if not ctx: + raise ValueError("Context is required to load artifacts") + name = spec.src.removeprefix("artifact://") + artifact_name, requested_ver = parse_artifact_ref(name) + use_ver = requested_ver + if use_ver is None and spec.pin: + use_ver = self._pinned_artifact_version(md, artifact_name, dst_rel) + content, actual_ver = await load_artifact_helper(ctx, artifact_name, use_ver) + await self._put_bytes_tar(content, dst_abs) + resolved = artifact_name + version = use_ver if use_ver is not None else actual_ver + elif spec.src.startswith("host://"): + host_path = spec.src.removeprefix("host://") + await self._stage_host_input(ws, host_path, dst_abs, mode, dst_rel) + resolved = host_path + elif spec.src.startswith("workspace://"): + rel = spec.src.removeprefix("workspace://") + src = str(Path(ws.path) / rel) + await self._stage_workspace_input(src, dst_abs, mode) + resolved = rel + elif spec.src.startswith("skill://"): + rest = spec.src.removeprefix("skill://") + src = str(Path(ws.path) / DIR_SKILLS / rest) + await self._stage_workspace_input(src, dst_abs, mode) + resolved = src + else: + raise RuntimeError(f"Unsupported input: {spec.src}") + + md.inputs.append( + InputRecordMeta( + src=spec.src, + dst=dst_rel, + resolved=resolved, + version=version, + mode=mode, + timestamp=datetime.now(), + )) + + await self._save_workspace_metadata(ws, md) + + logger.info("Staged %s inputs into workspace", len(specs)) + + @override + async def collect_outputs(self, + ws: WorkspaceInfo, + spec: WorkspaceOutputSpec, + ctx: Optional[InvocationContext] = None) -> ManifestOutput: + """ + Collect outputs from workspace according to specification. + + Args: + ws: Source workspace + spec: Output collection specification + ctx: Optional[InvocationContext] + + Returns: + Output manifest with collected files + + Raises: + RuntimeError: If collection fails + """ + matches = await self._enumerate_matches( + ws, + self._normalize_globs(spec.globs), + resolve_symlinks=False, + error_prefix="Failed to collect outputs", + ) + # Container refuses to persist a half-read artifact, preserving + # the historical "never save a truncated binary" guarantee. + manifest, _, _ = await self._build_manifest_output( + ws.path, + spec, + matches, + self._fetch_bytes, + ctx, + strict_truncated_save=True, + ) + logger.info("Collected %s output files", len(manifest.files)) + return manifest + + async def _enumerate_matches(self, ws: WorkspaceInfo, patterns: List[str], *, resolve_symlinks: bool, + error_prefix: str) -> List[str]: + """Run the glob inside the container, return absolute paths. + + ``collect`` historically resolved symlinks (via ``readlink -f``) + while ``collect_outputs`` did not; ``resolve_symlinks`` preserves + that distinction. ``error_prefix`` keeps the original per-caller + ``RuntimeError`` messages intact ("Failed to collect files" vs + "Failed to collect outputs") for backwards compatibility. + + Patterns may contain spaces (e.g. "my dir/*.txt"). The fix shape + — bash array + ``IFS=`` — mirrors the cube backend's ``_glob`` so + that a space-bearing pattern is neither word-split (which would + turn "my dir/*.txt" into two useless tokens "my" and + "dir/*.txt") nor quoted as a literal (which would disable + globbing). See ``cube/_runtime.py::_glob`` for the long-form + rationale. + """ + if not patterns: + return [] + array_literal = " ".join([_shell_quote(p) for p in patterns]) + if resolve_symlinks: + emit = ("(readlink -f \"$f\" 2>/dev/null " + "|| realpath \"$f\" 2>/dev/null " + "|| echo \"$(pwd)/$f\")") + else: + emit = "echo \"$(pwd)/$f\"" + cmd_str = (f"cd {_shell_quote(ws.path)} && " + f"shopt -s globstar nullglob dotglob; " + f"patterns=({array_literal}); " + f"_saved_ifs=$IFS; IFS=; " + f'for p in "${{patterns[@]}}"; do ' + f"matches=( $p ); " + f'for f in "${{matches[@]}}"; do ' + f'if [ -f "$f" ]; then {emit}; fi; ' + f"done; " + f"done; " + f"IFS=$_saved_ifs") + cmd = ["/bin/bash", "-lc", cmd_str] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code: + raise RuntimeError(f"{error_prefix}: {result.stderr}") + out: List[str] = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if line: + out.append(line) + return out + + async def _fetch_bytes(self, full_path: str, max_bytes: int) -> Tuple[bytes, int]: + """Fetcher contract for shared collection helpers. + + Copies the file out of the container via ``get_archive`` and + caps the returned slice at ``max_bytes``. ``raw_size`` is the + tar member's real size, so helpers can still flag truncation + even when ``max_bytes < raw_size``. + """ + data, raw_size, _ = self._copy_file_out(full_path, max_bytes=max_bytes) + return data, raw_size + + async def _put_directory(self, ws: WorkspaceInfo, src: str, dst: str) -> None: + """Copy directory to container using tar.""" + if not src or not str(src).strip(): + raise ValueError("source path is empty") + abs_src = os.path.abspath(src) + container_dst = str(Path(ws.path) / dst) if dst else ws.path + if self.config.skills_host_base: + rel_path = get_rel_path(self.config.skills_host_base, abs_src) + if rel_path: + container_src = str(Path(self.config.skills_container_base) / rel_path) + # Create destination directory + cmd = ["/bin/bash", "-lc", f"mkdir -p '{container_dst}' && cp -a '{container_src}/.' '{container_dst}'"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code == 0: + return None + logger.debug("Failed to stage directory via mount copy, fallback to tar: %s", result.stderr) + + cmd = ["/bin/bash", "-lc", f"[ -e '{container_dst}' ] || mkdir -p '{container_dst}'"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code: + raise RuntimeError(f"Failed to stage directory: {result.stderr}") + # Create tar archive + tar_stream = io.BytesIO() + with tarfile.open(fileobj=tar_stream, mode='w') as tar: + tar.add(abs_src, arcname='.') + + tar_stream.seek(0) + success = self.container.client.api.put_archive(self.container.container.id, container_dst, tar_stream) + + if not success: + raise RuntimeError(f"Failed to copy directory {src} to {container_dst}") + + async def _put_bytes_tar(self, data: bytes, dest: str, mode: int = 0o644) -> None: + """Copy bytes to container using tar.""" + # Create a tar with single file named as dest's base + base = Path(dest).name + tar_buffer = io.BytesIO() + with tarfile.open(fileobj=tar_buffer, mode='w') as tar: + tarinfo = tarfile.TarInfo(name=base) + tarinfo.size = len(data) + tarinfo.mode = mode + tarinfo.mtime = int(time.time()) + tar.addfile(tarinfo, io.BytesIO(data)) + # Reset buffer position to beginning + tar_buffer.seek(0) + # Ensure parent directory exists. Parent can be a symlink (for example + # work/inputs in container mode when auto_inputs is enabled), so avoid + # running plain `mkdir -p ` which may return "File exists". + parent = Path(dest).parent + cmd = ["/bin/bash", "-lc", f"[ -e '{parent.as_posix()}' ] || mkdir -p '{parent.as_posix()}'"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code: + raise RuntimeError(f"Failed to stage directory: {result.stderr}") + success = self.container.client.api.put_archive(self.container.container.id, parent.as_posix(), tar_buffer) + if not success: + raise RuntimeError(f"Failed to copy bytes to {dest}") + + async def _stage_host_input(self, ws: WorkspaceInfo, host: str, dst: str, mode: str, dst_rel: str) -> None: + """Stage input from host path.""" + if self.config.inputs_host_base: + rel_path = get_rel_path(self.config.inputs_host_base, host) + if rel_path: + container_src = str(Path(self.config.inputs_container_base) / rel_path) + + if mode == "link": + cmd_str = (f"parent='{Path(dst).parent}'; " + f"[ -e \"$parent\" ] || mkdir -p \"$parent\"; " + f"ln -sfn '{container_src}' '{dst}'") + else: + cmd_str = (f"parent='{Path(dst).parent}'; " + f"[ -e \"$parent\" ] || mkdir -p \"$parent\"; " + f"cp -a '{container_src}' '{dst}'") + + cmd = ["/bin/bash", "-lc", cmd_str] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0: + raise RuntimeError(f"Failed to stage host input: {result.stderr}") + return + # Fallback to tar copy + await self._put_directory(ws, host, str(Path(dst_rel).parent)) + + async def _stage_workspace_input(self, src: str, dst: str, mode: str) -> None: + """Stage input from workspace path.""" + parent = Path(dst).parent + if mode == "link": + cmd_str = (f"[ -e '{parent}' ] || mkdir -p '{parent}'; " + f"ln -sfn '{src}' '{dst}'") + else: + cmd_str = (f"[ -e '{parent}' ] || mkdir -p '{parent}'; " + f"cp -a '{src}' '{dst}'") + + cmd = ["/bin/bash", "-lc", cmd_str] + # await _exec_cmd(self.container, cmd, self.config.command_args) + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code: + raise RuntimeError(f"Failed to stage input: {result.stderr}") + + def _copy_file_out(self, full_path: str, *, max_bytes: int = MAX_READ_SIZE_BYTES) -> Tuple[bytes, int, str]: + """ + Copy file out of container. + + Args: + full_path: Full path to file in container + max_bytes: Upper bound on the returned byte slice; the + actual file size is still reported as ``size_bytes``. + + Returns: + Tuple of (file_data, size_bytes, mime_type) + + Raises: + RuntimeError: If copy fails + """ + try: + stream, _ = self.container.client.api.get_archive(self.container.container.id, full_path) + tar_stream = io.BytesIO(b''.join(stream)) + + with tarfile.open(fileobj=tar_stream, mode='r') as tar: + for member in tar.getmembers(): + if member.isfile(): + f = tar.extractfile(member) + data = f.read(max_bytes) + mime = self._detect_mime_type(data) + return data, member.size, mime + + raise RuntimeError(f"No file found in archive: {full_path}") + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to copy file out: %s", ex) + raise RuntimeError(f"Failed to copy file {full_path}: {ex}") + + @staticmethod + def _create_tar_from_files(files: List[WorkspacePutFileInfo]) -> io.BytesIO: + """Create tar archive from file list.""" + tar_stream = io.BytesIO() + + with tarfile.open(fileobj=tar_stream, mode='w') as tar: + for f in files: + tarinfo = tarfile.TarInfo(name=f.path) + tarinfo.size = len(f.content) + tarinfo.mode = f.mode + tarinfo.mtime = time.time() + tar.addfile(tarinfo, io.BytesIO(f.content)) + + tar_stream.seek(0) + return tar_stream + + @staticmethod + def _normalize_globs(patterns: List[str]) -> List[str]: + """Normalize glob patterns.""" + return normalize_globs(patterns) + + @staticmethod + def _input_base(src: str) -> str: + """Extract base name from input path.""" + s = (src or "").strip() + if s.startswith("artifact://"): + ref = s.removeprefix("artifact://") + try: + name, _ = parse_artifact_ref(ref) + base = Path(name.strip()).name + if base and base not in (".", "..", "/"): + return base + except Exception: # pylint: disable=broad-except + pass + return Path(s).name + + @staticmethod + def _pinned_artifact_version(md: Any, artifact_name: str, dst: str) -> Optional[int]: + for record in reversed(md.inputs or []): + if (record.dst or "") != dst: + continue + if record.version is None: + continue + if (record.resolved or "") == artifact_name: + return record.version + src = record.src or "" + if not src.startswith("artifact://"): + continue + try: + name, _ = parse_artifact_ref(src.removeprefix("artifact://")) + except Exception: # pylint: disable=broad-except + continue + if name == artifact_name: + return record.version + return None + + async def _load_workspace_metadata(self, ws: WorkspaceInfo): + now = datetime.now() + cmd = ["/bin/bash", "-lc", f"cat {_shell_quote(str(Path(ws.path) / META_FILE_NAME))}"] + result = await self.container.exec_run(cmd=cmd, command_args=self.config.command_args) + if result.exit_code != 0 or not result.stdout.strip(): + return WorkspaceMetadata(version=1, created_at=now, updated_at=now, last_access=now, skills={}) + try: + data = json.loads(result.stdout) + md = WorkspaceMetadata(**data) + except Exception as ex: # pylint: disable=broad-except + raise RuntimeError(f"Failed to parse workspace metadata: {ex}") from ex + if not md.version: + md.version = 1 + if md.created_at is None: + md.created_at = now + md.last_access = now + if md.skills is None: + md.skills = {} + return md + + async def _save_workspace_metadata(self, ws: WorkspaceInfo, md: Any) -> None: + now = datetime.now() + if not md.version: + md.version = 1 + if md.created_at is None: + md.created_at = now + md.updated_at = now + md.last_access = now + if md.skills is None: + md.skills = {} + payload = json.dumps(md.model_dump(exclude_none=True, by_alias=True, mode="json"), ensure_ascii=False, indent=2) + await self._put_bytes_tar(payload.encode("utf-8"), str(Path(ws.path) / META_FILE_NAME), mode=0o600) + + @staticmethod + def _detect_mime_type(data: bytes) -> str: + """Detect MIME type from file data.""" + # Simple detection based on content + if data.startswith(b'\x89PNG'): + return 'image/png' + elif data.startswith(b'\xff\xd8\xff'): + return 'image/jpeg' + elif data.startswith(b'%PDF'): + return 'application/pdf' + elif data.startswith(b'{') or data.startswith(b'['): + return 'application/json' + else: + return 'text/plain' + + +class ContainerProgramRunner(BaseProgramRunner): + """ + Docker container-based program runner implementation. + """ + + def __init__( + self, + container: ContainerClient, + config: RuntimeConfig, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ): + """ + Initialize container program runner. + + Args: + client: Docker client instance + container: Docker container to use + config: Runtime configuration + """ + super().__init__(provider=provider, enable_provider_env=enable_provider_env) + self.container = container + self.config = config + + @override + async def run_program(self, + ws: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ctx: Optional[InvocationContext] = None) -> WorkspaceRunResult: + """ + Execute a program in the workspace. + + Args: + ws: WorkspaceInfo to run in + spec: Program execution specification + ctx: Optional[InvocationContext] + Returns: + Execution result + + Raises: + RuntimeError: If execution fails + """ + spec = self._apply_provider_env(spec, ctx) + cwd = f"{ws.path}/{spec.cwd}" if spec.cwd else ws.path + + # Prepare directories + skills_dir = f"{ws.path}/{DIR_SKILLS}" + work_dir = f"{ws.path}/{DIR_WORK}" + out_dir = f"{ws.path}/{DIR_OUT}" + run_dir = f"{ws.path}/{DIR_RUNS}/run_{time.strftime('%Y%m%dT%H%M%S')}" + + # Build environment + base_env = { + WORKSPACE_ENV_DIR_KEY: ws.path, + ENV_SKILLS_DIR: skills_dir, + ENV_WORK_DIR: work_dir, + ENV_OUTPUT_DIR: out_dir, + ENV_RUN_DIR: run_dir, + } + + env_parts = [] + user_env = dict(spec.env or {}) + for k, v in base_env.items(): + if k not in user_env: + env_parts.append(f"{k}={_shell_quote(v)}") + + for k, v in user_env.items(): + env_parts.append(f"{k}={_shell_quote(v)}") + + env_str = " ".join(env_parts) + + # Build command line + cmd_parts = [ + f"mkdir -p {_shell_quote(run_dir)} {_shell_quote(out_dir)}", f"&& cd {_shell_quote(cwd)}", + "&& env" if env_str else "", env_str, + _shell_quote(spec.cmd) + ] + + for arg in spec.args: + cmd_parts.append(_shell_quote(arg)) + + cmd_str = " ".join(filter(None, cmd_parts)) + cmd = ["/bin/bash", "-lc", cmd_str] + + start_time = time.time() + if spec.timeout and spec.timeout > 0: + timeout = spec.timeout + elif self.config.command_args.timeout and self.config.command_args.timeout > 0: + timeout = self.config.command_args.timeout + else: + timeout = float(DEFAULT_TIMEOUT_SEC) + command_args = CommandArgs( + environment=None, + timeout=timeout, + stdin=spec.stdin or None, + ) + result = await self.container.exec_run(cmd=cmd, command_args=command_args) + return WorkspaceRunResult(stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + duration=time.time() - start_time, + timed_out=result.is_timeout) + + +class ContainerWorkspaceRuntime(BaseWorkspaceRuntime): + """ + Docker container-based execution engine. + """ + + def __init__( + self, + container: ContainerClient, + host_config: Optional[Dict] = None, + auto_inputs: bool = True, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ): + """ + Initialize container engine. + + Args: + client: Docker client instance + container: Docker container to use + host_config: Host configuration with binds + auto_inputs: Whether to auto-map inputs + """ + self.container = container + + # Build runtime configuration + config = RuntimeConfig(auto_map_inputs=auto_inputs) + + if host_config and 'Binds' in host_config: + config.skills_host_base = self._find_bind_source(host_config['Binds'], DEFAULT_SKILLS_CONTAINER) + config.inputs_host_base = self._find_bind_source(host_config['Binds'], DEFAULT_INPUTS_CONTAINER) + + self._fs = ContainerWorkspaceFS(self.container, config) + self._manager = ContainerWorkspaceManager(self.container, config, self._fs) + self._runner = ContainerProgramRunner( + self.container, + config, + provider=provider, + enable_provider_env=enable_provider_env, + ) + + @override + def manager(self, ctx: Optional[InvocationContext] = None) -> ContainerWorkspaceManager: + """Get workspace manager instance.""" + return self._manager + + @override + def fs(self, ctx: Optional[InvocationContext] = None) -> ContainerWorkspaceFS: + """Get workspace filesystem instance.""" + return self._fs + + @override + def runner(self, ctx: Optional[InvocationContext] = None) -> ContainerProgramRunner: + """Get program runner instance.""" + return self._runner + + @staticmethod + def _find_bind_source(binds: List[str], dest: str) -> str: + """ + Find host path for bind mount destination. + + Args: + binds: List of bind mount specifications + dest: Destination path in container + + Returns: + Host source path or empty string + """ + for bind in binds: + parts = bind.split(':') + if len(parts) < 2: + continue + + # Handle format: source:dest[:mode], parse from right. + bind_dest = parts[-2] + if bind_dest == dest: + source = ':'.join(parts[:-2]) if len(parts) > 2 else parts[0] + if Path(source).is_dir(): + return source + + return "" + + @override + def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabilities: + """Get the workspace capabilities.""" + return WorkspaceCapabilities( + isolation="container", + network_allowed=True, + read_only_mount=True, + streaming=True, + ) + + +def create_container_workspace_runtime( + container_config: Optional[ContainerConfig] = None, + host_config: Optional[Dict] = None, + auto_inputs: bool = True, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, +) -> ContainerWorkspaceRuntime: + """Create a new container workspace runtime. + Args: + container_config: Container configuration + host_config: Host configuration + auto_inputs: Whether to auto-map inputs + Returns: + ContainerWorkspaceRuntime instance + """ + if container_config: + cfg = ContainerConfig(base_url=container_config.base_url, + image=container_config.image, + docker_path=container_config.docker_path, + host_config=host_config) + container = ContainerClient(config=cfg) + else: + container = ContainerClient(config=ContainerConfig(host_config=host_config)) + return ContainerWorkspaceRuntime( + container=container, + host_config=host_config, + auto_inputs=auto_inputs, + provider=provider, + enable_provider_env=enable_provider_env, + ) diff --git a/trpc_agent_sdk/code_executors/cube/__init__.py b/trpc_agent_sdk/code_executors/cube/__init__.py new file mode 100644 index 000000000..47fe50e89 --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/__init__.py @@ -0,0 +1,44 @@ +# 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. +"""Cube/E2B code executor and workspace runtime. + +This subpackage requires the optional ``e2b-code-interpreter`` dependency +(install with ``pip install trpc-agent-py[cube]``); importing any module +here pulls it in eagerly. Code paths that don't need the Cube backend +should import from :mod:`trpc_agent_sdk.code_executors` instead — that +package never references this subpackage and therefore stays +``[cube]``-free. +""" + +from ._code_executor import CubeCodeExecutor +from ._runtime import CubeProgramRunner +from ._runtime import CubeWorkspaceFS +from ._runtime import CubeWorkspaceManager +from ._runtime import CubeWorkspaceRuntime +from ._runtime import create_cube_workspace_runtime +from ._sandbox import CubeCommandResult +from ._sandbox import CubeSandboxClient +from ._sandbox import create_cube_sandbox_client +from ._transfer import OnExisting +from ._types import CubeClientConfig +from ._types import CubeCodeExecutorConfig +from ._types import CubeWorkspaceRuntimeConfig + +__all__ = [ + "CubeCodeExecutor", + "CubeClientConfig", + "CubeCodeExecutorConfig", + "create_cube_sandbox_client", + "CubeCommandResult", + "CubeProgramRunner", + "CubeSandboxClient", + "CubeWorkspaceFS", + "CubeWorkspaceManager", + "CubeWorkspaceRuntime", + "CubeWorkspaceRuntimeConfig", + "OnExisting", + "create_cube_workspace_runtime", +] diff --git a/trpc_agent_sdk/code_executors/cube/_code_executor.py b/trpc_agent_sdk/code_executors/cube/_code_executor.py new file mode 100644 index 000000000..5c4b3a4ae --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_code_executor.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. +"""Cube/E2B code executor for the trpc-agent-py SDK.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Awaitable +from typing import Callable +from typing import Optional + +from typing_extensions import override + +import e2b_code_interpreter as e2b +from pydantic import Field +from pydantic import PrivateAttr + +from trpc_agent_sdk.context import InvocationContext + +from .._base_code_executor import BaseCodeExecutor +from .._base_code_executor import DEFAULT_CODE_BLOCK_DELIMITERS +from .._types import CodeBlock +from .._types import CodeBlockDelimiter +from .._types import CodeExecutionInput +from .._types import CodeExecutionResult +from .._types import create_code_execution_result +from ._sandbox import CubeCommandResult +from ._sandbox import CubeSandboxClient +from ._sandbox import create_cube_sandbox_client +from ._types import CubeCodeExecutorConfig + +_PYTHON_LANGUAGES = frozenset({"python", "py", "python3", ""}) +_BASH_LANGUAGES = frozenset({"bash", "sh"}) + +_BASH_DELIMITER = CodeBlockDelimiter(start="```bash\n", end="\n```") + + +class CubeCodeExecutor(BaseCodeExecutor): + """A code executor that runs blocks inside a Cube/E2B remote sandbox. + + Construct directly with an already-open :class:`CubeSandboxClient`:: + + executor = CubeCodeExecutor(client, cfg) + + For the typical use case (the SDK opens the sandbox for you) prefer + the async classmethod factories. All three read the bound sandbox id + from ``cfg.sandbox_id`` so it is the single source of truth — there + is no separate positional ``sandbox_id`` argument that could silently + override the config: + + - :meth:`create` — strict. If ``cfg.sandbox_id`` is set it attaches and + asserts the sandbox is RUNNING; otherwise it creates a fresh sandbox. + ``SandboxNotFoundException`` (gone) and ``SandboxException`` (PAUSED) + propagate so the caller decides whether to clear external locator + state and retry. + - :meth:`attach` — explicit attach-only variant; requires + ``cfg.sandbox_id`` to be set and never creates a fresh sandbox. + - :meth:`create_or_recreate` — convenience for callers (e.g. hermes) + that want the "attach, on NotFound run a callback then recreate" + pattern collapsed into a single call. + + `close()` is a no-op for the remote sandbox (drops the local handle + only). `destroy()` explicitly kills the remote sandbox; the caller + must call it when they no longer want the sandbox to outlive the + executor. + """ + + stateful: bool = Field(default=False, frozen=True, exclude=True) + optimize_data_file: bool = Field(default=False, frozen=True, exclude=True) + + code_block_delimiters: list[CodeBlockDelimiter] = Field( + default_factory=lambda: [*DEFAULT_CODE_BLOCK_DELIMITERS, _BASH_DELIMITER]) + + # `_client` is `Optional` because :meth:`close` / :meth:`destroy` + # legitimately drop the handle post-construction. `_cfg` has no such + # lifecycle and is set unconditionally in ``__init__``. + _client: Optional[CubeSandboxClient] = PrivateAttr(default=None) + _cfg: CubeCodeExecutorConfig = PrivateAttr() + + def __init__( + self, + client: CubeSandboxClient, + cfg: CubeCodeExecutorConfig, + **data, + ): + """Wrap an already-open :class:`CubeSandboxClient`. + + Prefer the async factories :meth:`create`, :meth:`attach`, or + :meth:`create_or_recreate` for typical use — they encapsulate the + lazy-import + connect/create plumbing. Direct construction is + for adapters that already own a :class:`CubeSandboxClient` (or + for tests that pass a fake). + + Raises: + ValueError: if the caller tries to enable ``stateful`` or + ``optimize_data_file`` (this executor does not support + either; the inherited :class:`Field` is frozen at + ``False``). + """ + if data.get("stateful"): + raise ValueError("CubeCodeExecutor cannot be stateful.") + if data.get("optimize_data_file"): + raise ValueError("CubeCodeExecutor cannot enable optimize_data_file.") + super().__init__(**data) + self._client = client + self._cfg = cfg + + @classmethod + async def create(cls, cfg: CubeCodeExecutorConfig) -> "CubeCodeExecutor": + """Strict factory. Attaches when ``cfg.sandbox_id`` is set, else creates.""" + client = await create_cube_sandbox_client(cfg) + return cls(client, cfg) + + @classmethod + async def attach(cls, cfg: CubeCodeExecutorConfig) -> "CubeCodeExecutor": + """Attach-only factory. + + Requires ``cfg.sandbox_id`` to be set. Always connects and asserts + the sandbox is RUNNING; never creates a fresh sandbox. + """ + if not cfg.sandbox_id: + raise ValueError("CubeCodeExecutor.attach requires cfg.sandbox_id to be set; " + "use CubeCodeExecutor.create(cfg) to create a fresh sandbox.") + client = await CubeSandboxClient.open_existing(cfg) + return cls(client, cfg) + + @classmethod + async def create_or_recreate( + cls, + cfg: CubeCodeExecutorConfig, + *, + on_stale: Optional[Callable[[], Awaitable[None]]] = None, + ) -> "CubeCodeExecutor": + """Attach-then-fall-back-to-create when the bound sandbox has expired. + + When ``cfg.sandbox_id`` is set and the remote sandbox is gone + (`SandboxNotFoundException`), ``on_stale`` is awaited (callers use + this to clear their persistent locator) and a fresh sandbox is + created. PAUSED state and other errors propagate unchanged so that + operator-managed pauses are not silently overwritten. + """ + try: + return await cls.create(cfg) + except e2b.SandboxNotFoundException: + if on_stale is not None: + await on_stale() + cfg = replace(cfg, sandbox_id=None) + return await cls.create(cfg) + + @property + def sandbox_id(self) -> str: + """The bound sandbox id. Caller persists it for cross-process reuse.""" + return self.sandbox_client.sandbox_id + + @property + def sandbox_client(self) -> CubeSandboxClient: + """The underlying :class:`CubeSandboxClient`. + + Useful for low-level callers (e.g. hermes' ``HarnessSandbox`` + adapter) that need direct file/exec primitives without going + through the workspace runtime contract. Always returns a live + client; raises :class:`RuntimeError` if the executor was closed. + """ + return self._require_client() + + @property + def config(self) -> CubeCodeExecutorConfig: + """Configuration this executor was created with.""" + return self._cfg + + async def assert_running(self) -> None: + """Re-validate the sandbox is RUNNING (e.g. before each turn).""" + await self._require_client().assert_running() + + def close(self) -> None: + """Drop the local sandbox handle. Does not kill the remote sandbox.""" + if self._client is not None: + self._client.close() + self._client = None + + async def destroy(self) -> None: + """Explicitly kill the remote sandbox.""" + if self._client is None: + return + try: + await self._client.destroy() + finally: + self._client = None + + @override + async def execute_code( + self, + invocation_context: InvocationContext, + code_execution_input: CodeExecutionInput, + ) -> CodeExecutionResult: + """Run each code block in the bound sandbox and aggregate output. + + Code is fed to the interpreter via stdin (heredoc-wrapped by + :meth:`CubeSandboxClient.commands_run`), which keeps multi-line + payloads with arbitrary quoting safe without shell-escaping the + whole block. Bash blocks are executed as a **login shell** + (``bash -l``) so ``/etc/profile``, ``/etc/profile.d/*`` and + ``~/.bash_profile`` populate ``PATH`` — Cube/E2B templates + commonly install toolchains (``uv``/``conda``/``nvm``/``rye``) + via setup scripts that hook into profile files rather than + through Dockerfile ``ENV PATH=…``, and a non-login shell would + silently fail to locate them. Python's ``-c``/stdin paths bypass + shell profile entirely, so Python blocks use plain ``python3``. + """ + client = self._require_client() + cfg = self.config + + blocks = list(code_execution_input.code_blocks) + if not blocks and code_execution_input.code: + blocks = [CodeBlock(code=code_execution_input.code, language="python")] + + stdouts: list[str] = [] + stderrs: list[str] = [] + any_timed_out = False + for index, block in enumerate(blocks): + if not block.code: + continue + try: + interpreter = self._select_interpreter(block.language) + except ValueError as exc: + stderrs.append(f"Error in code block {index}: {exc}\n") + continue + result = await client.commands_run( + interpreter, + stdin=block.code.encode("utf-8"), + timeout=cfg.execute_timeout, + ) + if result.timed_out: + any_timed_out = True + self._collect(result, stdouts, stderrs) + return create_code_execution_result( + stdout="".join(stdouts), + stderr="".join(stderrs), + is_timed_out=any_timed_out, + ) + + @staticmethod + def _select_interpreter(language: str) -> str: + """Pick the remote interpreter command for ``language``. + + Bash dispatches as ``bash -l`` so the block runs in a login + shell and inherits PATH from ``/etc/profile`` etc. Python uses + plain ``python3`` since the Python interpreter ignores shell + profile by design. + """ + lang = (language or "").lower() + if lang in _PYTHON_LANGUAGES: + return "python3" + if lang in _BASH_LANGUAGES: + return "bash -l" + raise ValueError(f"unsupported language: {language!r}") + + @staticmethod + def _collect(result: CubeCommandResult, stdouts: list[str], stderrs: list[str]) -> None: + if result.exit_code != 0: + stderrs.append(f"Process exited with code: {result.exit_code}\n") + if result.stderr: + stderrs.append(result.stderr) + if result.stdout: + stdouts.append(result.stdout) + + def _require_client(self) -> CubeSandboxClient: + if self._client is None: + raise RuntimeError("CubeCodeExecutor sandbox handle was closed; " + "construct a fresh executor.") + return self._client diff --git a/trpc_agent_sdk/code_executors/cube/_paths.py b/trpc_agent_sdk/code_executors/cube/_paths.py new file mode 100644 index 000000000..6452c304e --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_paths.py @@ -0,0 +1,114 @@ +# 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. +"""Pure remote-path and shell-quoting helpers for the Cube package. + +No e2b dependency, no ``AsyncSandbox`` reference — these are stateless +string utilities usable by any adapter that targets a Cube/E2B remote +workspace. Lives in its own module so :mod:`._sandbox`, :mod:`._transfer`, +:mod:`._runtime`, and future external adapters (e.g. hermes) can import +them without dragging in the sandbox client or the e2b extra. +""" + +from __future__ import annotations + +import base64 +import posixpath +import secrets + +# Random suffix prefix for the bash heredoc marker emitted by +# `wrap_stdin_heredoc`. Chosen to be unlikely to collide with payload +# content while remaining greppable in command logs. +_HEREDOC_MARKER_PREFIX = "TRPC_STDIN_EOF" + +# Width of base64 lines emitted on the binary heredoc path. 76 matches +# the canonical MIME wrapping width and keeps command logs readable. +_BASE64_LINE_WIDTH = 76 + + +def shell_quote(value: str) -> str: + """Single-quote a string for safe inclusion in a bash command line.""" + if not value: + return "''" + return "'" + value.replace("'", "'\\''") + "'" + + +def normalize_remote_relative(path: str, *, allow_current: bool = False) -> str: + """Normalize a relative remote path and reject escape attempts. + + Pure remote-path logic: this does not know about any host-side + workspace and never tries to map host absolute paths to + workspace-relative ones. + """ + if not path or not path.strip(): + if allow_current: + return "" + raise ValueError("cube remote path must not be empty.") + normalized = posixpath.normpath(path.strip().replace("\\", "/")) + if normalized in ("", "."): + if allow_current: + return "" + raise ValueError("cube remote path must not be empty.") + if normalized.startswith("/") or normalized == ".." or normalized.startswith("../"): + raise ValueError(f"cube remote path escapes its root: {path}") + return normalized + + +def join_remote(remote_root: str, relative: str) -> str: + """Join a relative path under a remote root and collapse ``..`` components.""" + if not relative: + return remote_root + return posixpath.normpath(posixpath.join(remote_root, relative)) + + +def wrap_stdin_heredoc(command: str, stdin: bytes) -> str: + """Embed ``stdin`` as a bash heredoc so the command receives it as input. + + The e2b SDK's ``commands.run(stdin=...)`` is a bool toggle, not a data + channel, so we transport the payload inside the command string + itself. Two paths are emitted depending on whether the payload is + valid UTF-8: + + - **Text fast path.** UTF-8 payloads are inlined verbatim as + ``{command} << 'MARKER'``. The rendered command stays readable + in logs and incurs no extra subprocess. + - **Binary path.** Non-UTF-8 payloads are base64-encoded and routed + through ``base64 -d | {command}`` so the original bytes reach the + command's stdin byte-for-byte. ``base64`` ships with coreutils + and is present in every Cube/E2B template. The base64 alphabet + (``A-Za-z0-9+/=``) cannot contain ``_``, so the heredoc marker + can never collide with the payload on this path. + + The marker collision check inspects *both* ``payload`` and + ``command`` — a marker accidentally embedded in the wrapper + command (e.g. a multi-line shell function whose body contains the + chosen literal) would otherwise close the heredoc prematurely. + + For shipping large binary blobs (assets, datasets, etc.), prefer + :meth:`CubeSandboxClient.upload_path` over piping through stdin. + """ + try: + payload = stdin.decode("utf-8") + except UnicodeDecodeError: + return _wrap_binary_stdin_heredoc(command, stdin) + marker = f"{_HEREDOC_MARKER_PREFIX}_{secrets.token_hex(8)}" + while marker in payload or marker in command: + marker = f"{_HEREDOC_MARKER_PREFIX}_{secrets.token_hex(8)}" + return f"{command} << '{marker}'\n{payload}\n{marker}" + + +def _wrap_binary_stdin_heredoc(command: str, stdin: bytes) -> str: + """Render a base64-on-the-wire heredoc for non-UTF-8 stdin payloads. + + The base64 alphabet excludes ``_`` so no marker collision with the + body is possible; the only retry case is a marker that already + appears inside ``command`` itself. + """ + encoded = base64.b64encode(stdin).decode("ascii") + body = "\n".join(encoded[i:i + _BASE64_LINE_WIDTH] for i in range(0, len(encoded), _BASE64_LINE_WIDTH)) + marker = f"{_HEREDOC_MARKER_PREFIX}_{secrets.token_hex(8)}" + while marker in command: + marker = f"{_HEREDOC_MARKER_PREFIX}_{secrets.token_hex(8)}" + return f"base64 -d << '{marker}' | {command}\n{body}\n{marker}" diff --git a/trpc_agent_sdk/code_executors/cube/_runtime.py b/trpc_agent_sdk/code_executors/cube/_runtime.py new file mode 100644 index 000000000..c9c01557f --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_runtime.py @@ -0,0 +1,506 @@ +# 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. +"""Workspace runtime backed by a Cube/E2B remote sandbox.""" + +from __future__ import annotations + +import os +import posixpath +import re +import time +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger + +from .._artifacts import load_artifact_helper +from .._artifacts import parse_artifact_ref +from .._base_workspace_runtime import BaseProgramRunner +from .._base_workspace_runtime import BaseWorkspaceFS +from .._base_workspace_runtime import BaseWorkspaceManager +from .._base_workspace_runtime import BaseWorkspaceRuntime +from .._base_workspace_runtime import RunEnvProvider +from .._constants import DEFAULT_TIMEOUT_SEC +from .._constants import DIR_OUT +from .._constants import DIR_RUNS +from .._constants import DIR_SKILLS +from .._constants import DIR_WORK +from .._constants import ENV_OUTPUT_DIR +from .._constants import ENV_RUN_DIR +from .._constants import ENV_SKILLS_DIR +from .._constants import ENV_WORK_DIR +from .._constants import WORKSPACE_ENV_DIR_KEY +from .._types import CodeFile +from .._types import ManifestOutput +from .._types import WorkspaceCapabilities +from .._types import WorkspaceInfo +from .._types import WorkspaceInputSpec +from .._types import WorkspaceOutputSpec +from .._types import WorkspacePutFileInfo +from .._types import WorkspaceRunProgramSpec +from .._types import WorkspaceRunResult +from .._types import WorkspaceStageOptions +from ..utils import normalize_globs +from ._code_executor import CubeCodeExecutor +from ._paths import join_remote +from ._paths import normalize_remote_relative +from ._paths import shell_quote +from ._sandbox import CubeSandboxClient +from ._types import CubeWorkspaceRuntimeConfig +from ._types import DEFAULT_EXECUTE_TIMEOUT + +_RE_SAFE_ID = re.compile(r"[^a-zA-Z0-9_-]") + + +def _input_default_name(src: str) -> str: + i = src.rfind("/") + if 0 <= i < len(src) - 1: + return src[i + 1:] + return src + + +class CubeWorkspaceManager(BaseWorkspaceManager): + """Creates per-execution workspaces under the configured ``remote_workspace`` root.""" + + def __init__(self, client: CubeSandboxClient, remote_workspace: str, command_timeout: float): + self._client = client + self._root = posixpath.normpath(remote_workspace) + self._timeout = command_timeout + self._ws_paths: dict[str, WorkspaceInfo] = {} + + @override + async def create_workspace(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> WorkspaceInfo: + # Reuse the previously minted path for the same exec_id so callers + # see a stable workspace location across calls. The cache is *not* + # trusted as proof that the remote dir still exists: the sandbox is + # remote and ephemeral, so any number of external events (operator + # cleanup, snapshot rollback, sibling cleanup() on a shared + # sandbox, host process restart re-attaching to a live sandbox) + # can delete the directory while this process is unaware. To stay + # in sync we unconditionally re-issue an idempotent ``mkdir -p`` + # for the four standard subdirs on every call. ``mkdir -p`` is a + # no-op when the tree already exists, so the steady-state cost is + # one round-trip; on miss the workspace heals transparently + # instead of letting downstream put_files / collect_outputs / + # stage_inputs fail deep inside with cryptic "No such file" errors. + cached = self._ws_paths.get(exec_id) + if cached is not None and cached.path: + ws_path = cached.path + else: + safe = _RE_SAFE_ID.sub("_", exec_id) if exec_id else "anon" + suffix = time.time_ns() + ws_path = posixpath.join(self._root, f"ws_{safe}_{suffix}") + + cmd = ("set -e; " + f"mkdir -p {shell_quote(ws_path)} " + f"{shell_quote(posixpath.join(ws_path, DIR_WORK))} " + f"{shell_quote(posixpath.join(ws_path, DIR_OUT))} " + f"{shell_quote(posixpath.join(ws_path, DIR_SKILLS))} " + f"{shell_quote(posixpath.join(ws_path, DIR_RUNS))}") + result = await self._client.commands_run(cmd, timeout=self._timeout) + if result.exit_code != 0: + raise RuntimeError(f"Failed to create cube workspace: {result.stderr or result.stdout}") + + if cached is not None and cached.path == ws_path: + logger.debug("Cube workspace reconciled: id=%s path=%s", exec_id, ws_path) + return cached + info = WorkspaceInfo(id=exec_id, path=ws_path) + self._ws_paths[exec_id] = info + logger.debug("Cube workspace created: id=%s path=%s", exec_id, ws_path) + return info + + @override + async def cleanup(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> None: + info = self._ws_paths.get(exec_id) + if not info or not info.path: + # Drop any stale entry that lacks a usable path so retries don't + # loop forever on a broken record. + self._ws_paths.pop(exec_id, None) + return + cmd = f"rm -rf {shell_quote(info.path)}" + result = await self._client.commands_run(cmd, timeout=self._timeout) + if result.exit_code != 0: + # Keep the cache entry intact so the caller can retry cleanup; + # popping prematurely would orphan the remote workspace because + # subsequent cleanup(exec_id) calls would hit the "unknown id" + # no-op branch. + raise RuntimeError(f"Failed to clean cube workspace: {result.stderr or result.stdout}") + self._ws_paths.pop(exec_id, None) + logger.debug("Cube workspace cleaned: id=%s path=%s", exec_id, info.path) + + +class CubeWorkspaceFS(BaseWorkspaceFS): + """Workspace-scoped filesystem operations that delegate to the client.""" + + def __init__(self, client: CubeSandboxClient, command_timeout: float): + self._client = client + self._timeout = command_timeout + + @override + async def put_files(self, + ws: WorkspaceInfo, + files: List[WorkspacePutFileInfo], + ctx: Optional[InvocationContext] = None) -> None: + for file in files: + if not file.path: + raise ValueError("empty file path") + relative = normalize_remote_relative(file.path) + remote = join_remote(ws.path, relative) + parent = posixpath.dirname(remote) + if parent and parent != ws.path: + await self._mkdir(parent) + await self._client.write_file_bytes(remote, file.content or b"") + logger.debug("Cube put %d files into %s", len(files), ws.path) + + @override + async def stage_directory(self, + ws: WorkspaceInfo, + src: str, + dst: str, + opt: WorkspaceStageOptions, + ctx: Optional[InvocationContext] = None) -> None: + if not src: + raise ValueError("stage_directory src is empty") + local = Path(os.path.abspath(src)) + if not local.exists() or not local.is_dir(): + raise FileNotFoundError(f"stage_directory src not found: {src}") + target = ws.path if not dst else join_remote(ws.path, normalize_remote_relative(dst, allow_current=True)) + await self._client.upload_path(local, target) + if opt.read_only: + # Surfacing chmod failures is critical: silently swallowing them + # would leave the directory writable while callers believe the + # read_only invariant was honoured. + result = await self._client.commands_run(f"chmod -R a-w {shell_quote(target)}", timeout=self._timeout) + if result.exit_code != 0: + raise RuntimeError(f"failed to make {target} read-only: {result.stderr or result.stdout}") + + @override + async def stage_inputs(self, + ws: WorkspaceInfo, + specs: List[WorkspaceInputSpec], + ctx: Optional[InvocationContext] = None) -> None: + for spec in specs: + if not spec.src: + continue + dst_rel = (spec.dst or "").strip() + if not dst_rel: + dst_rel = posixpath.join(DIR_WORK, "inputs", _input_default_name(spec.src)) + dst_rel = normalize_remote_relative(dst_rel) + dst_abs = join_remote(ws.path, dst_rel) + await self._mkdir(posixpath.dirname(dst_abs)) + + if spec.src.startswith("artifact://"): + if ctx is None: + raise ValueError("Context is required to load artifacts") + ref = spec.src.removeprefix("artifact://") + name, version = parse_artifact_ref(ref) + content, _ = await load_artifact_helper(ctx, name, version) + await self._client.write_file_bytes(dst_abs, content) + elif spec.src.startswith("host://"): + host_path = Path(spec.src.removeprefix("host://")) + if not host_path.exists(): + raise FileNotFoundError(f"host path not found: {host_path}") + await self._client.upload_path(host_path, dst_abs) + elif spec.src.startswith("workspace://"): + rel = normalize_remote_relative(spec.src.removeprefix("workspace://")) + src_abs = join_remote(ws.path, rel) + await self._copy_remote(src_abs, dst_abs) + elif spec.src.startswith("skill://"): + rest = normalize_remote_relative(spec.src.removeprefix("skill://")) + src_abs = join_remote(join_remote(ws.path, DIR_SKILLS), rest) + await self._copy_remote(src_abs, dst_abs) + else: + raise ValueError(f"unsupported input scheme: {spec.src!r}") + logger.debug("Cube staged %d inputs into %s", len(specs), ws.path) + + @override + async def collect(self, + ws: WorkspaceInfo, + patterns: List[str], + ctx: Optional[InvocationContext] = None) -> List[CodeFile]: + matches = await self._glob(ws.path, normalize_globs(patterns)) + files = await self._build_code_files(ws.path, matches, self._fetch_file) + logger.debug("Cube collected %d files from %s", len(files), ws.path) + return files + + @override + async def collect_outputs(self, + ws: WorkspaceInfo, + spec: WorkspaceOutputSpec, + ctx: Optional[InvocationContext] = None) -> ManifestOutput: + matches = await self._glob(ws.path, normalize_globs(spec.globs)) + manifest, _, _ = await self._build_manifest_output(ws.path, spec, matches, self._fetch_file, ctx) + logger.debug("Cube collected %d outputs from %s", len(manifest.files), ws.path) + return manifest + + async def _fetch_file(self, full_path: str, max_bytes: int) -> Tuple[bytes, int]: + """Fetcher contract for :meth:`BaseWorkspaceFS._build_code_files` / + :meth:`BaseWorkspaceFS._build_manifest_output`. + + Cube exposes no cheap ``stat`` RPC, so we read the full payload + and slice locally; ``raw_size`` reflects the true on-disk size + so the shared helpers can still report ``truncated`` / + ``limits_hit`` accurately. + """ + data = await self._client.read_file_bytes(full_path) + return data[:max_bytes], len(data) + + async def _mkdir(self, remote_abs: str) -> None: + if not remote_abs: + return + result = await self._client.commands_run(f"mkdir -p {shell_quote(remote_abs)}", timeout=self._timeout) + if result.exit_code != 0: + raise RuntimeError(f"mkdir -p failed: {result.stderr or result.stdout}") + + async def _copy_remote(self, src: str, dst: str) -> None: + await self._mkdir(posixpath.dirname(dst)) + # Defensive rm before cp -a to avoid the long-standing POSIX + # directory-footgun: when DST already exists as a directory, + # ``cp -a SRC DST`` copies SRC *into* DST as DST/basename(SRC), + # nesting stale data instead of replacing it. Removing DST first + # makes the operation idempotent across repeated stage_inputs + # calls targeting the same destination. + # + # Safety: ``dst`` is supplied exclusively by :meth:`stage_inputs`, + # which routes the caller-provided ``spec.dst`` through + # :func:`normalize_remote_relative` (rejects empty, absolute, and + # ``..``-bearing relatives) and :func:`join_remote` (collapses + # ``..`` after joining under ``ws.path``). ``shell_quote`` then + # neutralises any shell metacharacters in the resulting absolute + # path, and GNU ``rm``'s default ``--preserve-root`` is the + # backstop. New callers of ``_copy_remote`` MUST funnel ``dst`` + # through the same validation chain. + rm_result = await self._client.commands_run( + f"rm -rf {shell_quote(dst)}", + timeout=self._timeout, + ) + if rm_result.exit_code != 0: + raise RuntimeError(f"remote rm failed: {rm_result.stderr or rm_result.stdout}") + result = await self._client.commands_run( + f"cp -a {shell_quote(src)} {shell_quote(dst)}", + timeout=self._timeout, + ) + if result.exit_code != 0: + raise RuntimeError(f"remote cp failed: {result.stderr or result.stdout}") + + async def _glob(self, ws_path: str, patterns: List[str]) -> List[str]: + if not patterns: + return [] + # Patterns may contain spaces (e.g. "my dir/*.txt"). The naive shape + # `for f in $p` first word-splits $p on IFS *and only then* globs each + # token separately — turning "my dir/*.txt" into two patterns "my" + # and "dir/*.txt", neither of which matches. Quoting `"$p"` would + # suppress word-splitting but also disables globbing. + # + # Fix: pass patterns via a bash array (preserves spaces per element), + # then temporarily set IFS= so the unquoted `$p` inside `matches=( $p )` + # is *not* word-split, while bash still performs path expansion on it. + # `compgen -G` is not used here because it does not honour `globstar`. + array_literal = " ".join(shell_quote(p) for p in patterns) + cmd = (f"cd {shell_quote(ws_path)} && " + f"shopt -s globstar nullglob dotglob; " + f"patterns=({array_literal}); " + f"_saved_ifs=$IFS; IFS=; " + f'for p in "${{patterns[@]}}"; do ' + f"matches=( $p ); " + f'for f in "${{matches[@]}}"; do ' + f'[ -f "$f" ] && printf \'%s\\n\' "$(pwd)/$f"; ' + f"done; " + f"done; " + f"IFS=$_saved_ifs") + result = await self._client.commands_run(cmd, timeout=self._timeout) + if result.exit_code != 0: + raise RuntimeError(f"glob failed: {result.stderr or result.stdout}") + out: List[str] = [] + for line in result.stdout.splitlines(): + line = line.strip() + if line: + out.append(line) + return out + + +class CubeProgramRunner(BaseProgramRunner): + """Runs ``WorkspaceRunProgramSpec`` jobs inside the Cube sandbox. + + Follows the workspace-relative-cwd semantic shared by Container/Local + runners (`WorkspaceRunProgramSpec.cwd` is rooted at ``ws.path``) and + aligns with `LocalProgramRunner` in auto-creating the resolved cwd. + """ + + def __init__( + self, + client: CubeSandboxClient, + command_timeout: float, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ): + super().__init__(provider=provider, enable_provider_env=enable_provider_env) + self._client = client + self._timeout = command_timeout + + @override + async def run_program(self, + ws: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ctx: Optional[InvocationContext] = None) -> WorkspaceRunResult: + spec = self._apply_provider_env(spec, ctx) + cwd = ws.path if not spec.cwd else join_remote(ws.path, normalize_remote_relative(spec.cwd, allow_current=True)) + + run_dir = join_remote(ws.path, posixpath.join(DIR_RUNS, f"run_{time.strftime('%Y%m%dT%H%M%S')}")) + out_dir = join_remote(ws.path, DIR_OUT) + skills_dir = join_remote(ws.path, DIR_SKILLS) + work_dir = join_remote(ws.path, DIR_WORK) + + env: dict[str, str] = { + WORKSPACE_ENV_DIR_KEY: ws.path, + ENV_SKILLS_DIR: skills_dir, + ENV_WORK_DIR: work_dir, + ENV_OUTPUT_DIR: out_dir, + ENV_RUN_DIR: run_dir, + } + env.update(spec.env or {}) + + # Single shell pipeline: ensure run_dir + cwd exist, cd, exec command. + parts = [ + "set -e", + f"mkdir -p {shell_quote(run_dir)} {shell_quote(cwd)}", + f"cd {shell_quote(cwd)}", + ] + argv = [shell_quote(spec.cmd)] + [shell_quote(arg) for arg in (spec.args or [])] + parts.append(" ".join(argv)) + shell_cmd = "; ".join(parts) + + timeout = float(spec.timeout) if spec.timeout and spec.timeout > 0 else float(DEFAULT_TIMEOUT_SEC) + stdin_bytes = spec.stdin.encode("utf-8") if spec.stdin else None + + start = time.time() + result = await self._client.commands_run( + shell_cmd, + env=env, + stdin=stdin_bytes, + timeout=timeout, + ) + return WorkspaceRunResult( + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + duration=time.time() - start, + timed_out=result.timed_out, + ) + + +class CubeWorkspaceRuntime(BaseWorkspaceRuntime): + """Cube/E2B-backed workspace runtime. + + Depends only on the public :class:`CubeSandboxClient` primitive, not on + :class:`CubeCodeExecutor`. Use :func:`create_cube_workspace_runtime` + when you have an executor and want to share its sandbox; pass a client + directly when integrating with a non-executor caller. + """ + + def __init__( + self, + client: CubeSandboxClient, + *, + remote_workspace: str, + execute_timeout: float, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ): + self._client = client + self._fs = CubeWorkspaceFS(self._client, execute_timeout) + self._manager = CubeWorkspaceManager(self._client, remote_workspace, execute_timeout) + self._runner = CubeProgramRunner( + self._client, + execute_timeout, + provider=provider, + enable_provider_env=enable_provider_env, + ) + + @property + def sandbox_id(self) -> str | None: + """Current Cube sandbox id.""" + return self._client.sandbox_id + + async def recreate(self) -> None: + """Force sandbox recreation when the client supports it.""" + await self._client.recreate() + + async def destroy(self) -> None: + """Destroy the current Cube sandbox/client.""" + await self._client.destroy() + + @override + def manager(self, ctx: Optional[InvocationContext] = None) -> CubeWorkspaceManager: + return self._manager + + @override + def fs(self, ctx: Optional[InvocationContext] = None) -> CubeWorkspaceFS: + return self._fs + + @override + def runner(self, ctx: Optional[InvocationContext] = None) -> CubeProgramRunner: + return self._runner + + @override + def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabilities: + return WorkspaceCapabilities( + isolation="cube", + network_allowed=True, + read_only_mount=False, + streaming=False, + ) + + +def create_cube_workspace_runtime( + executor: CubeCodeExecutor | None = None, + sandbox_client: CubeSandboxClient | None = None, + execute_timeout: float = DEFAULT_EXECUTE_TIMEOUT, + workspace_cfg: Optional[CubeWorkspaceRuntimeConfig] = None, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, +) -> CubeWorkspaceRuntime: + """Construct a :class:`CubeWorkspaceRuntime` sharing ``executor``'s sandbox. + + Convenience wrapper that: + + - reuses the live :class:`CubeSandboxClient` already opened by + ``executor`` (no second remote handshake), + - takes ``execute_timeout`` from ``executor.config`` (sandbox-wide + command timeout — naturally shared with the runtime), and + - takes workspace-only settings from ``workspace_cfg`` (defaulting + to :class:`CubeWorkspaceRuntimeConfig` defaults when omitted). + + For lower-level integrations, construct :class:`CubeWorkspaceRuntime` + directly with an explicit client + ``remote_workspace`` + + ``execute_timeout``. + Args: + executor: CubeCodeExecutor instance, will deprecated, will be removed in the future + sandbox_client: CubeSandboxClient instance, required + execute_timeout: execute timeout, default to DEFAULT_EXECUTE_TIMEOUT + workspace_cfg: workspace configuration, default to CubeWorkspaceRuntimeConfig() + provider: provider, default to None + enable_provider_env: enable provider environment, default to False + Returns: + CubeWorkspaceRuntime instance + """ + if executor: + sandbox_client = executor.sandbox_client + execute_timeout = executor.config.execute_timeout + if not sandbox_client: + raise ValueError("sandbox_client is required") + ws_cfg = workspace_cfg or CubeWorkspaceRuntimeConfig() + return CubeWorkspaceRuntime( + sandbox_client, + remote_workspace=ws_cfg.remote_workspace, + execute_timeout=execute_timeout, + provider=provider, + enable_provider_env=enable_provider_env, + ) diff --git a/trpc_agent_sdk/code_executors/cube/_sandbox.py b/trpc_agent_sdk/code_executors/cube/_sandbox.py new file mode 100644 index 000000000..e6d432efb --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_sandbox.py @@ -0,0 +1,425 @@ +# 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. +"""Cube/E2B sandbox client. + +Owns the :class:`AsyncSandbox` lifetime and exposes the few primitives +the SDK code executor and workspace runtime are built on top of: + +- **Lifecycle** — :meth:`open_new`, :meth:`open_existing`, :meth:`close`, + :meth:`destroy`, :meth:`assert_running`, :meth:`set_timeout`. +- **Command execution** — :meth:`commands_run` (always returns a + structured :class:`CubeCommandResult`; non-zero exit codes never + raise). +- **File primitives** — :meth:`upload_path` / :meth:`download_path` + (auto-dispatch file vs directory; directories go through the tar + protocol in :mod:`._transfer`), plus + :meth:`read_file_bytes` / :meth:`write_file_bytes`. + +Pure path/quote helpers live in :mod:`._paths`. The tar-based directory +transfer protocol lives in :mod:`._transfer`. This module is +intentionally the only place that holds an ``AsyncSandbox`` reference +and therefore is the only place that needs to absorb e2b's quirks +(``CommandExitException`` / ``"STOPPED"`` / +``SandboxNotFoundException``). + +``e2b_code_interpreter`` is imported at module top-level. It is +distributed as the optional ``[cube]`` extra (``pip install +trpc-agent-py[cube]``); any code path that reaches this module is by +construction a Cube-backend caller and therefore must have the extra +installed. A missing extra surfaces as a normal :class:`ImportError` +at import time, which is the right place for the failure to land. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from dataclasses import replace +from pathlib import Path +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Mapping +from typing import Optional +from typing import TypeVar + +import e2b_code_interpreter as e2b +from e2b_code_interpreter import AsyncSandbox + +from trpc_agent_sdk.log import logger + +from ._paths import wrap_stdin_heredoc +from ._transfer import OnExisting +from ._transfer import download_directory_via_tar +from ._transfer import reserve_local_destination +from ._transfer import upload_directory_via_tar +from ._types import CubeClientConfig + +# The unix user we run sandbox commands and FS ops as. Standard cube/e2b +# templates ship with `root`; downstream callers do not need to override +# this and we deliberately do not expose a knob to keep the surface small. +_GUEST_USER = "root" +_T = TypeVar("_T") + + +def _is_stale_sandbox_error(exc: BaseException) -> bool: + """Return whether ``exc`` means the remote sandbox disappeared.""" + if isinstance(exc, e2b.SandboxNotFoundException): + return True + message = str(exc).lower() + return "code.unknown" in message and "requested resource does not exist" in message + + +@dataclass +class CubeCommandResult: + """Structured result of a single command run inside the sandbox. + + Non-zero exit codes are returned, not raised. This intentionally + absorbs the e2b SDK's :class:`CommandExitException` so callers always + see a structured return value (matches the local/container + code-executor behavior). + + The ``timed_out`` flag distinguishes a deadline-exceeded run from a + plain non-zero exit: e2b raises :class:`e2b.TimeoutException` when + the per-command ``timeout`` is hit, and ``commands_run`` catches it + so callers never see the raw exception. When ``timed_out`` is ``True`` + the process has already been killed by e2b; ``exit_code`` is set to + ``-1`` (mirroring the local/container executors' convention) and + ``stderr`` carries a short, hand-written description rather than the + e2b SDK's verbose original message. + """ + + stdout: str + stderr: str + exit_code: int + duration: float + timed_out: bool = False + + +class CubeSandboxClient: + """Thin public wrapper around an :class:`AsyncSandbox` with SDK semantics. + + Holds the lifetime of one Cube/E2B remote sandbox and exposes the + primitives :class:`CubeCodeExecutor` and :class:`CubeWorkspaceRuntime` + are built on top of. External adapters (e.g. hermes' ``HarnessSandbox``) + can also depend on this directly without pulling in the workspace + runtime contract. + + Semantics: + + - ``close()`` is a no-op (drops the local handle only). + - ``destroy()`` is the only place that calls ``kill()`` and tolerates + the "already STOPPED" / :class:`SandboxNotFoundException` + workarounds. + - ``commands_run()`` always returns a structured result; non-zero + exit codes never raise, and deadline-exceeded runs surface as + ``CubeCommandResult(timed_out=True, exit_code=-1)`` rather than + propagating e2b's :class:`TimeoutException`. + - ``upload_path`` / ``download_path`` auto-dispatch file vs directory + and preserve symlinks/perms via tar (see :mod:`._transfer`). + + Construct via :meth:`open_new` or :meth:`open_existing` rather than + the constructor directly. + """ + + def __init__(self, sandbox: AsyncSandbox, cfg: CubeClientConfig): + self._sbx: Optional[AsyncSandbox] = sandbox + self._cfg = cfg + self._recreate_cfg = replace(cfg, sandbox_id=None) + self._idle_timeout = cfg.idle_timeout + self._execute_timeout = cfg.execute_timeout + self._recreate_lock = asyncio.Lock() + + @property + def sandbox_id(self) -> str: + return self._require().sandbox_id + + @classmethod + async def open_new(cls, cfg: CubeClientConfig) -> "CubeSandboxClient": + """Create a brand-new remote sandbox.""" + sbx = await e2b.AsyncSandbox.create( + template=cfg.resolve_template(), + api_url=cfg.resolve_api_url(), + api_key=cfg.resolve_api_key(), + timeout=cfg.idle_timeout, + ) + return cls(sbx, cfg) + + @classmethod + async def open_existing( + cls, + cfg: CubeClientConfig, + ) -> "CubeSandboxClient": + """Attach to an existing remote sandbox and assert it is RUNNING. + + Raises: + SandboxNotFoundException: the sandbox is gone (caller decides + whether to clear its locator and recreate). + SandboxException: the sandbox is in a non-RUNNING state (e.g. + PAUSED); caller should not silently overwrite locator + state. + """ + if not cfg.sandbox_id: + raise ValueError("CubeSandboxClient.open_existing requires cfg.sandbox_id") + sbx = await e2b.AsyncSandbox.connect( + cfg.sandbox_id, + api_url=cfg.resolve_api_url(), + api_key=cfg.resolve_api_key(), + ) + client = cls(sbx, cfg) + await client.assert_running() + return client + + def close(self) -> None: + """Drop the local sandbox handle. Never kills the remote sandbox.""" + self._sbx = None + + async def destroy(self) -> None: + """Explicitly kill the remote sandbox. + + Tolerates :class:`SandboxNotFoundException` (already gone) and + :class:`SandboxException` whose message contains ``"STOPPED"`` + (Cube refuses kill on already-stopped instances). Other errors + propagate. + """ + sbx = self._sbx + if sbx is None: + return + try: + await sbx.kill() + except e2b.SandboxNotFoundException as exc: + logger.info("Cube sandbox %s already gone during kill: %s", sbx.sandbox_id, exc) + except e2b.SandboxException as exc: + if "STOPPED" in str(exc): + logger.info("Cube sandbox %s already stopped during kill: %s", sbx.sandbox_id, exc) + else: + raise + finally: + self._sbx = None + + async def recreate(self) -> None: + """Explicitly replace the current sandbox with a fresh one.""" + async with self._recreate_lock: + await self._recreate_locked() + + async def assert_running(self) -> None: + """Verify the sandbox is RUNNING; reject PAUSED and surface stale ids. + + - ``get_info`` raises :class:`SandboxNotFoundException` if + killed/expired. + - PAUSED state raises :class:`SandboxException` so callers do + not silently discard operator-managed pause state. + """ + await self._with_recovery(self._assert_running_once) + + async def set_timeout(self, seconds: int) -> None: + """Best-effort idle-timeout renewal. + + ``seconds`` is integer because the underlying e2b ``set_timeout`` + takes integer seconds; previously a ``float`` would be silently + truncated by ``int(...)`` (e.g. ``0.9`` → ``0``, which most + vendor APIs interpret as "no timeout" / "expire immediately"). + """ + await self._with_recovery(lambda: self._set_timeout_once(seconds)) + + async def commands_run( + self, + command: str, + *, + cwd: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, + stdin: Optional[bytes] = None, + timeout: Optional[float] = None, + ) -> CubeCommandResult: + """Run a single shell command and return a structured result. + + Non-zero exit codes never raise. Deadline-exceeded runs never + raise either: the e2b SDK's :class:`e2b.TimeoutException` is + caught here and turned into a :class:`CubeCommandResult` with + ``timed_out=True`` and ``exit_code=-1``, mirroring the + local/container executors so upstream callers see a single, + unified shape for "command did not succeed". Stdin (when + provided) is encoded as a bash heredoc because the e2b SDK's + ``stdin`` flag is not a data channel. + """ + return await self._with_recovery(lambda: self._commands_run_once( + command, + cwd=cwd, + env=env, + stdin=stdin, + timeout=timeout, + )) + + async def upload_path(self, local: Path, remote_abs: str) -> None: + """Upload a host file or directory to an absolute remote path. + + Directories go through the tar protocol so symlinks, permissions + and special files are preserved in one round-trip. Single files + and directories alike route through the client's own + :meth:`write_file_bytes` / :meth:`commands_run`, so all e2b + ``user=`` plumbing and ``CommandExitException`` absorption stays + DRY. + """ + if local.is_dir(): + await upload_directory_via_tar(self, local, remote_abs) + return + await self.write_file_bytes(remote_abs, local.read_bytes()) + + async def download_path( + self, + remote_abs: str, + local: Path, + *, + on_existing: OnExisting = "error", + ) -> None: + """Download a remote file or directory to a host path. + + Args: + remote_abs: Absolute remote path to download. + local: Host destination path. + on_existing: Collision policy when ``local`` already exists. + ``"error"`` (default) refuses to clobber; ``"replace"`` + removes the existing destination first; ``"merge"`` + overlays the tar payload onto an existing directory + (siblings not in the payload are preserved). For + file/symlink destinations ``"merge"`` behaves like + ``"replace"`` because a regular file cannot be merged + into. Missing destinations and empty directories are + accepted regardless of this flag. + """ + is_remote_dir = await self._is_remote_dir(remote_abs) + + reserve_local_destination(local, on_existing=on_existing) + local.parent.mkdir(parents=True, exist_ok=True) + if is_remote_dir: + await download_directory_via_tar(self, remote_abs, local) + return + local.write_bytes(await self.read_file_bytes(remote_abs)) + + async def read_file_bytes(self, remote_abs: str) -> bytes: + """Read a remote file's raw bytes.""" + data = await self._with_recovery( + lambda: self._require().files.read(remote_abs, format="bytes", user=_GUEST_USER)) + return data if isinstance(data, bytes) else bytes(data or b"") + + async def write_file_bytes(self, remote_abs: str, data: bytes) -> None: + """Write raw bytes to a remote file.""" + await self._with_recovery(lambda: self._require().files.write(remote_abs, data, user=_GUEST_USER)) + + async def _is_remote_dir(self, remote_abs: str) -> bool: + """Return whether ``remote_abs`` resolves to a directory inside the sandbox.""" + info = await self._with_recovery(lambda: self._require().files.get_info(remote_abs, user=_GUEST_USER)) + return info.type == e2b.FileType.DIR + + async def _assert_running_once(self) -> None: + sbx = self._require() + info = await sbx.get_info(request_timeout=self._execute_timeout) + if info.state != e2b.SandboxState.RUNNING: + raise e2b.SandboxException(f"Cube sandbox {sbx.sandbox_id} is in state {info.state.value!r}, " + f"expected {e2b.SandboxState.RUNNING.value!r}.") + + async def _set_timeout_once(self, seconds: int) -> None: + sbx = self._require() + try: + await sbx.set_timeout(seconds) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.debug("Cube sandbox %s set_timeout failed: %s", sbx.sandbox_id, exc) + + async def _commands_run_once( + self, + command: str, + *, + cwd: Optional[str] = None, + env: Optional[Mapping[str, str]] = None, + stdin: Optional[bytes] = None, + timeout: Optional[float] = None, + ) -> CubeCommandResult: + sbx = self._require() + if stdin is not None: + command = wrap_stdin_heredoc(command, stdin) + timeout_sec = float(timeout if timeout is not None else self._execute_timeout) + kwargs: dict[str, Any] = { + "envs": dict(env or {}), + "user": _GUEST_USER, + "timeout": timeout_sec, + } + if cwd: + kwargs["cwd"] = cwd + + loop = asyncio.get_running_loop() + start = loop.time() + timed_out = False + try: + result = await sbx.commands.run(command, **kwargs) + except e2b.CommandExitException as exc: + result = exc + except BaseException as exc: + # Timeouts surface here as one of several types depending on + # which transport layer fires first: + # - e2b.TimeoutException (vendor SDK layer) + # - httpcore.ReadTimeout / httpcore.TimeoutException + # (transport layer — can race ahead of the e2b mapping on + # slow Cube deployments) + # The httpcore path is only reachable via the transitive + # dependency, so we match by type-name instead of importing + # httpcore just to subclass-check. We still re-raise anything + # that is not timeout-flavoured so real errors stay visible. + name = type(exc).__name__ + if "Timeout" not in name: + raise + result = None + timed_out = True + duration = loop.time() - start + + await self.set_timeout(self._idle_timeout) + + if timed_out: + return CubeCommandResult( + stdout="", + stderr=f"Command timed out after {timeout_sec:g}s", + exit_code=-1, + duration=float(duration), + timed_out=True, + ) + return CubeCommandResult( + stdout=str(getattr(result, "stdout", "") or ""), + stderr=str(getattr(result, "stderr", "") or ""), + exit_code=int(getattr(result, "exit_code", 0) or 0), + duration=float(duration), + ) + + async def _with_recovery(self, op: Callable[[], Awaitable[_T]]) -> _T: + sandbox = self._sbx + try: + return await op() + except Exception as exc: + if not self._cfg.auto_recover or not _is_stale_sandbox_error(exc): + raise + logger.info("Cube sandbox expired; recreating sandbox client: %s", exc) + async with self._recreate_lock: + if self._sbx is sandbox: + await self._recreate_locked() + return await op() + + async def _recreate_locked(self) -> None: + if self._sbx is not None: + await self.destroy() + fresh = await type(self).open_new(self._recreate_cfg) + self._sbx = fresh._require() + fresh.close() + logger.info("Cube sandbox client using sandbox: %s", self.sandbox_id) + + def _require(self) -> AsyncSandbox: + if self._sbx is None: + raise RuntimeError("CubeSandboxClient is closed.") + return self._sbx + + +async def create_cube_sandbox_client(cfg: CubeClientConfig) -> CubeSandboxClient: + """Create or attach a Cube sandbox client from config.""" + if cfg.sandbox_id: + return await CubeSandboxClient.open_existing(cfg) + return await CubeSandboxClient.open_new(cfg) diff --git a/trpc_agent_sdk/code_executors/cube/_transfer.py b/trpc_agent_sdk/code_executors/cube/_transfer.py new file mode 100644 index 000000000..fc06a6a8e --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_transfer.py @@ -0,0 +1,198 @@ +# 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. +"""Tar-based directory transfer protocol for the Cube package. + +Self-contained protocol layered on :class:`CubeSandboxClient`'s public +primitives (:meth:`commands_run`, :meth:`read_file_bytes`, +:meth:`write_file_bytes`). Used by :meth:`CubeSandboxClient.upload_path` +/ :meth:`download_path` to round-trip whole directory trees while +preserving symlinks, permissions, and special files (mirrors +:class:`ContainerWorkspaceFS` semantics). + +Kept separate from :mod:`._sandbox` so the client itself stays focused +on lifecycle/command/file primitives, and so this protocol can be unit +tested against a fake :class:`CubeSandboxClient`-shaped object that only +exposes ``commands_run`` / ``read_file_bytes`` / ``write_file_bytes``. + +This module deliberately does **not** import e2b — all vendor quirks +(``CommandExitException`` absorption, ``user=`` plumbing, idle-timeout +renewal) are absorbed inside :class:`CubeSandboxClient`. Any change to +those quirks happens in exactly one place. +""" + +from __future__ import annotations + +import io +import posixpath +import secrets +import shutil +import tarfile +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Literal + +from ._paths import shell_quote + +if TYPE_CHECKING: + # Quoted forward reference to break the runtime import cycle: + # `_sandbox.py` imports the transfer functions, and the transfer + # functions in turn want the `CubeSandboxClient` *type* (for + # type-checkers / IDEs) but only its *duck-typed* surface at runtime. + from ._sandbox import CubeSandboxClient + +# Collision-handling mode for ``download_path`` when the local +# destination already exists. See :func:`reserve_local_destination`. +# +# - ``"error"`` — refuse to clobber (default). Non-empty dir / existing +# file / existing symlink → :class:`FileExistsError`. +# - ``"replace"``— remove the existing destination before extracting. +# Directories are ``shutil.rmtree``'d (symlinks are +# ``unlink``'d first to avoid following the link). +# - ``"merge"`` — overlay onto an existing directory: leave siblings +# intact and let the tar payload write its own entries +# on top. Existing files/symlinks at the destination +# name are still unlinked (you cannot merge into a +# regular file). +OnExisting = Literal["error", "replace", "merge"] + + +def reserve_local_destination( + local: Path, + *, + on_existing: OnExisting = "error", +) -> None: + """Enforce ``download_path``'s collision policy on the local target. + + - Missing destinations and empty directories are accepted regardless + of ``on_existing`` — there is no content to clobber. + - ``"error"`` (default) raises :class:`FileExistsError` when the + destination is a non-empty directory, a regular file, or any + symlink (including broken symlinks — the name is taken). + - ``"replace"`` removes the existing destination (``shutil.rmtree`` + for directories, ``unlink`` for files/symlinks) so the caller + extracts into a clean slot. + - ``"merge"`` leaves an existing non-empty directory in place so the + tar payload overlays its entries; for file/symlink destinations + it still unlinks because a regular file cannot be merged into. + """ + # Missing path: nothing to reserve. ``is_symlink()`` handles the + # broken-symlink case where ``exists()`` returns False but the name + # is still taken. + if not local.exists() and not local.is_symlink(): + return + + is_real_dir = local.is_dir() and not local.is_symlink() + if is_real_dir: + try: + next(local.iterdir()) + except StopIteration: + return + if on_existing == "error": + raise FileExistsError(f"download destination is non-empty " + f"(pass on_existing='replace' or 'merge' to resolve): {local}") + if on_existing == "replace": + shutil.rmtree(local) + # "merge": leave the directory in place; tar.extractall overlays. + return + + # File or symlink (regular file, symlink-to-file, symlink-to-dir, + # broken symlink). A regular file cannot be "merged" into — merge + # falls back to replace for non-dir destinations. + if on_existing == "error": + raise FileExistsError(f"download destination already exists " + f"(pass on_existing='replace' to overwrite): {local}") + local.unlink() + + +async def upload_directory_via_tar( + client: "CubeSandboxClient", + local_dir: Path, + remote_abs: str, +) -> None: + """Upload an entire host directory to ``remote_abs`` via tar. + + The whole tree (symlinks, permissions, special files) is preserved + in a single round-trip. Requires ``tar`` in the sandbox image (true + for any standard unix template). + """ + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tar: + tar.add(str(local_dir), arcname=".") + payload = buf.getvalue() + + token = secrets.token_hex(8) + temp_remote = f"/tmp/.cube_upload_{token}.tar" + normalized = posixpath.normpath(remote_abs) + try: + await client.write_file_bytes(temp_remote, payload) + extract_cmd = (f"set -e; mkdir -p {shell_quote(normalized)}; " + f"tar -xf {shell_quote(temp_remote)} -C {shell_quote(normalized)}") + await _run_protocol_step(client, extract_cmd, op="upload tar extract") + finally: + await _run_protocol_step( + client, + f"rm -f {shell_quote(temp_remote)}", + op="upload tar cleanup", + swallow=True, + ) + + +async def download_directory_via_tar( + client: "CubeSandboxClient", + remote_dir: str, + local: Path, +) -> None: + """Download an entire remote directory tree to ``local`` via tar. + + Round-trip pair of :func:`upload_directory_via_tar`; symlinks, + permissions, and special files are preserved. + """ + token = secrets.token_hex(8) + temp_remote = f"/tmp/.cube_download_{token}.tar" + try: + create_cmd = f"tar -cf {shell_quote(temp_remote)} -C {shell_quote(remote_dir)} ." + await _run_protocol_step(client, create_cmd, op="download tar create") + payload = await client.read_file_bytes(temp_remote) + finally: + await _run_protocol_step( + client, + f"rm -f {shell_quote(temp_remote)}", + op="download tar cleanup", + swallow=True, + ) + + if local.exists() and not local.is_dir(): + local.unlink() + local.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(payload), mode="r") as tar: + try: + tar.extractall(local, filter="data") # type: ignore[arg-type] # py>=3.12 + except TypeError: + tar.extractall(local) # noqa: S202 — py3.10/3.11 fallback + + +async def _run_protocol_step( + client: "CubeSandboxClient", + command: str, + *, + op: str, + swallow: bool = False, +) -> None: + """Run a transfer-protocol shell step (mkdir/tar/rm) and surface failures. + + Goes through the client's :meth:`commands_run` so all e2b vendor + quirks (``CommandExitException`` absorption, ``user=`` plumbing, + idle-timeout renewal) are handled in exactly one place. Distinct + from :meth:`CubeSandboxClient.commands_run` only in that we *raise* + on non-zero exit instead of returning the structured result — + these commands are invariants of the transfer contract, so a failed + ``mkdir``/``tar`` means the transfer didn't happen and the caller + can't sensibly continue. ``swallow=True`` is reserved for + best-effort cleanup steps (rm-on-finally). + """ + result = await client.commands_run(command) + if result.exit_code != 0 and not swallow: + raise RuntimeError(f"cube {op} failed (exit={result.exit_code}): {result.stderr}") diff --git a/trpc_agent_sdk/code_executors/cube/_types.py b/trpc_agent_sdk/code_executors/cube/_types.py new file mode 100644 index 000000000..4d25c609b --- /dev/null +++ b/trpc_agent_sdk/code_executors/cube/_types.py @@ -0,0 +1,131 @@ +# 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. +"""Configuration types for the Cube/E2B code executor.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +DEFAULT_REMOTE_WORKSPACE = "/workspace/cube_agent" +DEFAULT_EXECUTE_TIMEOUT = 60.0 +DEFAULT_IDLE_TIMEOUT = 3600 + +ENV_API_URL = "E2B_API_URL" +ENV_API_KEY = "E2B_API_KEY" +ENV_TEMPLATE = "CUBE_TEMPLATE_ID" + + +@dataclass +class CubeClientConfig: + """Configuration for :class:`CubeSandboxClient`. + + Holds only the sandbox-lifecycle and command-execution settings the + bare sandbox client consumes. Workspace-runtime knobs (e.g. the + remote workspace root) live in :class:`CubeWorkspaceRuntimeConfig` + so client-only callers never see fields they don't use (ISP). + + Credentials may be supplied here or through ``E2B_API_URL`` / ``E2B_API_KEY``. The Cube template id + may be supplied here or through ``CUBE_TEMPLATE_ID``. + """ + + template: Optional[str] = None + """Cube template id for new sandboxes. Falls back to ``CUBE_TEMPLATE_ID``.""" + + api_url: Optional[str] = None + """E2B-compatible Cube API URL. Falls back to ``E2B_API_URL``.""" + + api_key: Optional[str] = None + """E2B API key. Falls back to ``E2B_API_KEY``.""" + + sandbox_id: Optional[str] = None + """Existing remote sandbox id. When set, factories attach instead of create.""" + + auto_recover: bool = False + """Whether ``CubeSandboxClient`` should recreate expired sandboxes. + + Disabled by default to preserve the original lifecycle contract. When + enabled, sandbox operations recreate a fresh sandbox after + ``SandboxNotFoundException`` and retry the failed operation once. + """ + + execute_timeout: float = DEFAULT_EXECUTE_TIMEOUT + """Default per-command timeout in seconds. + + ``float`` because per-command latency can legitimately be sub-second + (short shell commands, tight test loops). Shared by the bare + executor and (transitively) by :class:`CubeWorkspaceRuntime`, since + the runtime drives commands through the same + :class:`CubeSandboxClient` and therefore inherits its default. Stays + on the executor cfg because the client itself reads it during + construction. + """ + + idle_timeout: int = DEFAULT_IDLE_TIMEOUT + """Sandbox idle lifetime in seconds; renewed on every command. + + ``int`` (not ``float``) because the underlying e2b APIs + (``AsyncSandbox.create(timeout=...)`` and ``sbx.set_timeout(...)``) + take integer seconds — sub-second precision is meaningless for a + sandbox lifetime measured in minutes/hours. Typing the field as + ``int`` lets static checkers reject ``idle_timeout=0.9`` at the call + site instead of silently truncating it to ``0`` (which most vendor + APIs interpret as "no timeout" or "expire immediately"). + """ + + def __post_init__(self) -> None: + if not isinstance(self.idle_timeout, int) or isinstance(self.idle_timeout, bool): + raise TypeError(f"idle_timeout must be an int (seconds), got " + f"{type(self.idle_timeout).__name__}: {self.idle_timeout!r}") + if self.idle_timeout < 1: + raise ValueError(f"idle_timeout must be >= 1 second, got {self.idle_timeout}") + if self.execute_timeout <= 0: + raise ValueError(f"execute_timeout must be > 0 seconds, got {self.execute_timeout}") + + def resolve_template(self) -> str: + value = self.template or os.getenv(ENV_TEMPLATE) + if not value: + raise ValueError(f"Cube sandbox requires `template` or {ENV_TEMPLATE} env.") + return value + + def resolve_api_url(self) -> str: + value = self.api_url or os.getenv(ENV_API_URL) + if not value: + raise ValueError(f"Cube sandbox requires `api_url` or {ENV_API_URL} env.") + return value + + def resolve_api_key(self) -> str: + value = self.api_key or os.getenv(ENV_API_KEY) + if not value: + raise ValueError(f"Cube sandbox requires `api_key` or {ENV_API_KEY} env.") + return value + + +# Deprecated, will be removed in the future +CubeCodeExecutorConfig = CubeClientConfig + + +@dataclass +class CubeWorkspaceRuntimeConfig: + """Configuration for :class:`CubeWorkspaceRuntime`. + + Carries the workspace-only settings the bare :class:`CubeCodeExecutor` + does not consume. Kept distinct from :class:`CubeCodeExecutorConfig` + so: + + - executor-only callers (e.g. an agent that just runs code blocks) + never see workspace knobs in their type signatures, and + - future workspace-only fields (``max_upload_size``, custom subdir + names, stage timeouts, ...) can be added here without polluting + the executor cfg. + """ + + remote_workspace: str = DEFAULT_REMOTE_WORKSPACE + """Remote root under which :class:`CubeWorkspaceManager` creates + ``ws__`` subtrees. Defaults to + :data:`DEFAULT_REMOTE_WORKSPACE`. + """ diff --git a/trpc_agent_sdk/code_executors/local/__init__.py b/trpc_agent_sdk/code_executors/local/__init__.py new file mode 100644 index 000000000..4c7944d34 --- /dev/null +++ b/trpc_agent_sdk/code_executors/local/__init__.py @@ -0,0 +1,25 @@ +# 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. +"""Local code executors for TRPC Agent framework. + +This module provides local code executor implementations, including UnsafeLocalCodeExecutor. +""" + +from ._local_ws_runtime import LocalProgramRunner +from ._local_ws_runtime import LocalWorkspaceFS +from ._local_ws_runtime import LocalWorkspaceManager +from ._local_ws_runtime import LocalWorkspaceRuntime +from ._local_ws_runtime import create_local_workspace_runtime +from ._unsafe_local_code_executor import UnsafeLocalCodeExecutor + +__all__ = [ + "LocalProgramRunner", + "LocalWorkspaceFS", + "LocalWorkspaceManager", + "LocalWorkspaceRuntime", + "create_local_workspace_runtime", + "UnsafeLocalCodeExecutor", +] diff --git a/trpc_agent_sdk/code_executors/local/_local_program_session.py b/trpc_agent_sdk/code_executors/local/_local_program_session.py new file mode 100644 index 000000000..fb1be8a20 --- /dev/null +++ b/trpc_agent_sdk/code_executors/local/_local_program_session.py @@ -0,0 +1,299 @@ +# 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. +"""WorkspaceInfo runtime for local code execution. + +This module provides the WorkspaceRuntime class which allows local code execution. +It provides methods for staging directories and inputs into the workspace. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import time +import uuid +from typing import Optional +from typing_extensions import override + +from .._program_session import BaseProgramSession +from .._program_session import PROGRAM_STATUS_EXITED +from .._program_session import PROGRAM_STATUS_RUNNING +from .._program_session import ProgramLog +from .._program_session import ProgramPoll +from .._program_session import ProgramState +from .._types import WorkspaceRunResult + +_DEFAULT_INTERACTIVE_MAX_LINES = 20_000 +if sys.platform != "win32": + import fcntl + + +def _split_lines_with_partial(text: str) -> tuple[list[str], str]: + normalized = text.replace("\r\n", "\n") + parts = normalized.split("\n") + if len(parts) == 1: + return [], parts[0] + return parts[:-1], parts[-1] + + +class LocalProgramSession(BaseProgramSession): + """Local interactive subprocess session.""" + + def __init__( + self, + process: asyncio.subprocess.Process, + *, + max_lines: int = _DEFAULT_INTERACTIVE_MAX_LINES, + master_fd: Optional[int] = None, + ) -> None: + self._id = uuid.uuid4().hex + self._process = process + self._max_lines = max_lines + self._master_fd = master_fd + self._lock = asyncio.Lock() + self._closed = False + + self._started_at = time.time() + self._finished_at: Optional[float] = None + self._exit_code: Optional[int] = None + self._timed_out = False + + self._line_base = 0 + self._lines: list[str] = [] + self._partial = "" + self._poll_cursor = 0 + + self._stdout = "" + self._stderr = "" + if self._master_fd is not None: + self._stdout_task = asyncio.create_task(self._read_pty(self._master_fd)) + self._stderr_task = asyncio.create_task(asyncio.sleep(0)) + else: + self._stdout_task = asyncio.create_task(self._read_stream(self._process.stdout, stream_name="stdout")) + self._stderr_task = asyncio.create_task(self._read_stream(self._process.stderr, stream_name="stderr")) + self._wait_task = asyncio.create_task(self._watch_process_exit()) + + @override + def id(self) -> str: + return self._id + + async def _read_stream(self, reader: Optional[asyncio.StreamReader], *, stream_name: str) -> None: + if reader is None: + return + while True: + chunk = await reader.read(4096) + if not chunk: + return + await self._append_output(chunk.decode("utf-8", errors="replace"), stream=stream_name) + + async def _read_pty(self, master_fd: int) -> None: + loop = asyncio.get_running_loop() + flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) + fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK) + + read_event = asyncio.Event() + + def _on_readable() -> None: + read_event.set() + + loop.add_reader(master_fd, _on_readable) + try: + while True: + read_event.clear() + if self._process.returncode is not None: + while True: + try: + data = os.read(master_fd, 4096) + if not data: + break + await self._append_output(data.decode("utf-8", errors="replace"), stream="stdout") + except BlockingIOError: + break + except OSError: + break + return + + try: + await asyncio.wait_for(read_event.wait(), timeout=0.05) + except asyncio.TimeoutError: + pass + try: + data = os.read(master_fd, 4096) + if data: + await self._append_output(data.decode("utf-8", errors="replace"), stream="stdout") + except BlockingIOError: + pass + except OSError: + return + finally: + loop.remove_reader(master_fd) + + async def _append_output(self, chunk: str, *, stream: str) -> None: + normalized = chunk.replace("\r\n", "\n") + async with self._lock: + if stream == "stderr": + self._stderr += normalized + else: + self._stdout += normalized + + merged = self._partial + normalized + lines, self._partial = _split_lines_with_partial(merged) + self._lines.extend(lines) + self._trim_lines_locked() + + async def _watch_process_exit(self) -> None: + code = await self._process.wait() + await asyncio.gather(self._stdout_task, self._stderr_task, return_exceptions=True) + async with self._lock: + if self._finished_at is not None: + return + if self._partial: + self._lines.append(self._partial) + self._partial = "" + self._trim_lines_locked() + self._exit_code = code + self._finished_at = time.time() + + def _trim_lines_locked(self) -> None: + if self._max_lines <= 0: + return + if len(self._lines) <= self._max_lines: + return + drop = len(self._lines) - self._max_lines + self._lines = self._lines[drop:] + self._line_base += drop + if self._poll_cursor < self._line_base: + self._poll_cursor = self._line_base + + @override + async def poll(self, limit: Optional[int] = None) -> ProgramPoll: + async with self._lock: + start = self._poll_cursor + if start < self._line_base: + start = self._line_base + self._poll_cursor = start + end = self._line_base + len(self._lines) + if limit is not None and limit > 0: + end = min(end, start + limit) + + out = "" + if end > start: + out = "\n".join(self._lines[start - self._line_base:end - self._line_base]) + if end == self._line_base + len(self._lines) and self._partial: + out = f"{out}\n{self._partial}" if out else self._partial + + self._poll_cursor = end + status = PROGRAM_STATUS_RUNNING if self._finished_at is None else PROGRAM_STATUS_EXITED + return ProgramPoll( + status=status, + output=out, + offset=start, + next_offset=end, + exit_code=self._exit_code, + ) + + @override + async def log(self, offset: Optional[int] = None, limit: Optional[int] = None) -> ProgramLog: + async with self._lock: + start = self._line_base if offset is None else offset + end = self._line_base + len(self._lines) + + if start < self._line_base: + start = self._line_base + if start > end: + start = end + if limit is not None and limit > 0: + end = min(end, start + limit) + + out = "" + if end > start: + out = "\n".join(self._lines[start - self._line_base:end - self._line_base]) + if end == self._line_base + len(self._lines) and self._partial: + out = f"{out}\n{self._partial}" if out else self._partial + return ProgramLog(output=out, offset=start, next_offset=end) + + @override + async def write(self, data: str, newline: bool) -> None: + if not data and not newline: + return + if self._process.returncode is not None: + raise ValueError("session is not running") + text = data + if newline: + text += "\n" + if self._master_fd is not None: + try: + os.write(self._master_fd, text.encode("utf-8")) + return + except OSError as ex: + raise ValueError("stdin is not available") from ex + stdin = self._process.stdin + if stdin is None: + raise ValueError("stdin is not available") + stdin.write(text.encode("utf-8")) + await stdin.drain() + + @override + async def kill(self, grace_seconds: float) -> None: + if self._process.returncode is not None: + return + self._process.terminate() + try: + await asyncio.wait_for(self._process.wait(), timeout=max(0.0, grace_seconds)) + return + except asyncio.TimeoutError: + pass + if self._process.returncode is None: + self._process.kill() + await self._process.wait() + + @override + async def close(self) -> None: + async with self._lock: + if self._closed: + return + self._closed = True + if self._process.returncode is None: + await self.kill(0.5) + if self._process.stdin is not None: + try: + self._process.stdin.close() + except Exception: # pylint: disable=broad-except + pass + await asyncio.gather(self._stdout_task, self._stderr_task, self._wait_task, return_exceptions=True) + if self._master_fd is not None: + try: + os.close(self._master_fd) + except OSError: + pass + + async def enforce_timeout(self, timeout_sec: float) -> None: + if timeout_sec <= 0: + return + await asyncio.sleep(timeout_sec) + if self._process.returncode is None: + self._timed_out = True + await self.kill(0.5) + + @override + async def state(self) -> ProgramState: + if self._finished_at is None: + return ProgramState(status=PROGRAM_STATUS_RUNNING) + return ProgramState(status=PROGRAM_STATUS_EXITED, exit_code=self._exit_code) + + @override + async def run_result(self) -> WorkspaceRunResult: + duration = 0.0 + if self._finished_at is not None: + duration = self._finished_at - self._started_at + return WorkspaceRunResult( + stdout=self._stdout, + stderr=self._stderr, + exit_code=self._exit_code or 0, + duration=duration, + timed_out=self._timed_out, + ) diff --git a/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py b/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py new file mode 100644 index 000000000..466e6a01d --- /dev/null +++ b/trpc_agent_sdk/code_executors/local/_local_ws_runtime.py @@ -0,0 +1,716 @@ +# 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. +"""WorkspaceInfo runtime for local code execution. + +This module provides the WorkspaceRuntime class which allows local code execution. +It provides methods for staging directories and inputs into the workspace. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import sys +import tempfile +import time +from datetime import datetime +from pathlib import Path +from typing import List +from typing import Optional +from typing import Tuple +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.utils import async_execute_command + +from .._artifacts import load_artifact_helper +from .._artifacts import parse_artifact_ref +from .._base_workspace_runtime import BaseWorkspaceManager +from .._base_workspace_runtime import BaseWorkspaceFS +from .._base_workspace_runtime import BaseProgramRunner +from .._base_workspace_runtime import BaseWorkspaceRuntime +from .._base_workspace_runtime import RunEnvProvider + +from .._constants import DEFAULT_FILE_MODE +from .._constants import DEFAULT_TIMEOUT_SEC +from .._constants import DIR_OUT +from .._constants import DIR_RUNS +from .._constants import DIR_SKILLS +from .._constants import DIR_WORK +from .._constants import ENV_OUTPUT_DIR +from .._constants import ENV_RUN_DIR +from .._constants import ENV_SKILLS_DIR +from .._constants import ENV_WORK_DIR +from .._constants import WORKSPACE_ENV_DIR_KEY +from .._types import CodeFile +from .._types import WorkspaceInfo +from .._types import WorkspacePutFileInfo +from .._types import WorkspaceInputSpec +from .._types import WorkspaceRunProgramSpec +from .._types import WorkspaceRunResult +from .._types import WorkspaceCapabilities +from .._types import WorkspaceStageOptions +from .._types import ManifestOutput +from .._types import WorkspaceOutputSpec +from .._program_session import BaseProgramSession +from ..utils import ensure_layout +from ..utils import load_metadata +from ..utils import save_metadata +from ..utils import InputRecordMeta +from ..utils import OutputRecordMeta +from ..utils import normalize_globs +from ..utils import collect_files_with_glob +from ..utils import make_symlink +from ..utils import copy_path +from ..utils import path_join + +if sys.platform != "win32": + import pty + +from ._local_program_session import LocalProgramSession + + +class LocalWorkspaceManager(BaseWorkspaceManager): + """Local workspace manager for executing commands in skill workspaces.""" + + def __init__(self, + work_root: str, + auto_inputs: bool = True, + inputs_host_base: str = "", + fs: BaseWorkspaceFS = None): + if not work_root: + work_root = tempfile.gettempdir() + self.work_root = work_root + self.auto_inputs = auto_inputs + self.inputs_host_base = inputs_host_base + self.fs = fs + self.ws_paths: dict[str, WorkspaceInfo] = {} + + @override + async def create_workspace(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> WorkspaceInfo: + """Create a new workspace. + + Args: + ctx: Context for the operation + exec_id: Execution ID + + Returns: + The workspace information. + """ + if exec_id in self.ws_paths: + return self.ws_paths[exec_id] + # Sanitize exec_id to be filesystem friendly + safe = re.sub(r'[^a-zA-Z0-9_-]', '_', exec_id) + + # Make workspace path unique to avoid collisions + suffix = time.time_ns() + ws_path = Path(self.work_root) / f"ws_{safe}_{suffix}" + ws_path.mkdir(parents=True, exist_ok=True) + ws_path.chmod(0o777) + + # Ensure standard layout and metadata + ensure_layout(ws_path) + + ws = WorkspaceInfo(id=exec_id, path=ws_path.as_posix()) + + # Auto-map inputs if configured + if self.auto_inputs and self.inputs_host_base: + specs = [ + WorkspaceInputSpec(src=f"host://{self.inputs_host_base}", dst=str(Path("work") / "inputs"), mode="link") + ] + await self.fs.stage_inputs(ws, specs, ctx) + + self.ws_paths[exec_id] = ws + return ws + + @override + async def cleanup(self, exec_id: str, ctx: Optional[InvocationContext] = None) -> None: + """Clean up a workspace. + + Args: + ctx: Context for the operation + exec_id: Execution ID + """ + ws = self.ws_paths.get(exec_id) + if not ws or not ws.path: + return + + path = Path(ws.path) + if path.exists(): + shutil.rmtree(path) + self.ws_paths.pop(exec_id, None) + + +class LocalWorkspaceFS(BaseWorkspaceFS): + """Local workspace file system for executing commands in skill workspaces.""" + + def __init__(self, read_only_staged_skill: bool = False): + self.read_only_staged_skill = read_only_staged_skill + + @staticmethod + def _normalize_stage_mode(mode: str) -> str: + mode = (mode or "copy").strip().lower() + if mode not in {"copy", "link"}: + raise ValueError(f"unsupported local workspace stage mode: {mode!r}") + return mode + + @override + async def put_files( + self, + ws: WorkspaceInfo, + files: List[WorkspacePutFileInfo], + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Write file blobs under the workspace root. + + Args: + ctx: Context for the operation + ws: Target workspace + files: Files to write + """ + for file in files: + self._write_file_safe(ws.path, file) + + @override + async def stage_directory( + self, + ws: WorkspaceInfo, + src: str, + dst: str, + opt: WorkspaceStageOptions, + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Stage a host directory into the workspace. + + Args: + ctx: Context for the operation + ws: Target workspace + src: Source directory path + to: Destination path relative to workspace + opt: Staging options + """ + mode = self._normalize_stage_mode(opt.mode) + self._put_directory(ws, src, dst, mode=mode) + + # Make tree read-only if requested + ro = opt.read_only or self.read_only_staged_skill + # Link mode uses symlinks to host files. chmod would follow symlinks on + # many platforms and mutate the source skill tree, so leave protection + # to the skill stager's symlink-aware chmod pass. + if ro and mode != "link": + if dst: + dst = Path(ws.path) / Path(dst) + else: + dst = Path(ws.path) + self._make_tree_read_only(dst) + + def _put_directory( + self, + ws: WorkspaceInfo, + src: str, + dst: str, + mode: str = "copy", + ) -> None: + """ + Put a host path into workspace using copy or link mode. + + Args: + ctx: Context for the operation + ws: Target workspace + host_path: Source directory path on host + to: Destination path relative to workspace + """ + src = os.path.abspath(src) + dst = path_join(ws.path, dst) + src_path = Path(src) + if mode == "link" and src_path.is_dir(): + self._link_directory(src, dst) + elif mode == "link": + make_symlink(ws.path, os.path.relpath(dst, ws.path), src) + else: + copy_path(src, dst) + + def _link_directory( + self, + src: str, + dst: str, + ) -> None: + """Stage a directory by symlinking its direct children. + + The destination root is a real directory so later staging logic can add + workspace-local links such as ``out`` / ``work`` without mutating the + original skill directory. Direct child directories such as ``scripts`` + or ``references`` are linked as directories to avoid walking large + skill trees. + """ + src_path = Path(src) + dst_path = Path(dst) + if dst_path.exists() or dst_path.is_symlink(): + if dst_path.is_symlink() or dst_path.is_file(): + dst_path.unlink() + elif not dst_path.is_dir(): + raise ValueError(f"cannot stage into non-directory path: {dst}") + dst_path.mkdir(parents=True, exist_ok=True) + + for item in src_path.iterdir(): + target = dst_path / item.name + if target.exists() or target.is_symlink(): + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + else: + target.unlink() + target.symlink_to(item.resolve(strict=False), target_is_directory=item.is_dir()) + + def _make_tree_read_only( + self, + dst: Path, + ) -> None: + """Remove write bits from entire tree. + + Args: + dst: Destination directory path + """ + for path in dst.rglob("*"): + if path.is_file(): + current_mode = path.stat().st_mode + new_mode = current_mode & ~0o222 # Clear write bits + path.chmod(new_mode) + + @override + async def collect(self, + ws: WorkspaceInfo, + patterns: List[str], + ctx: Optional[InvocationContext] = None) -> List[CodeFile]: + """Collect files from the workspace. + + Find output files by glob patterns relative to workspace root. + + Args: + ctx: Context for the operation + ws: Target workspace + patterns: Glob patterns to match + + Returns: + List of matching file references + """ + real_root, matches = self._enumerate_local_matches(ws.path, normalize_globs(patterns)) + return await self._build_code_files(real_root, matches, self._fetch_bytes) + + def _enumerate_local_matches( + self, + ws_path: str, + patterns: List[str], + ) -> Tuple[str, List[str]]: + """Expand ``patterns`` under ``ws_path`` into absolute paths. + + Resolves symlinks and drops anything that escapes the + canonicalised workspace root, preserving the security property + that the pre-refactor hand-written loop used to enforce. + + Returns ``(real_root, matches)`` where ``real_root`` is the + canonicalised workspace root and ``matches`` is the list of + canonical absolute paths under it. Both are passed to + :meth:`_build_code_files` / :meth:`_build_manifest_output` as + a matched pair so the helpers' prefix-stripping ``_relativize`` + operates on canonical-vs-canonical paths. Passing the raw + (un-resolved) ``ws.path`` would silently leak absolute paths as + ``CodeFile.name`` whenever ``ws.path`` itself contains a symlink + component (e.g. macOS ``/tmp`` → ``/private/tmp``, Linux scratch + bind-mounts), because the canonical match would not start with + the un-canonical prefix. + """ + root = Path(ws_path) + try: + real_root = root.resolve() + except Exception: # pylint: disable=broad-except + real_root = root + real_root_str = real_root.as_posix() + + seen: set[str] = set() + out: List[str] = [] + for pattern in patterns: + for match_path in collect_files_with_glob(ws_path, pattern): + m_abs = Path("/" + match_path.lstrip("/")) + try: + m_abs.relative_to(root) + except ValueError: + continue + try: + real_path = m_abs.resolve() + except Exception: # pylint: disable=broad-except + real_path = m_abs + try: + # Re-check containment against canonical root. + real_path.relative_to(real_root) + except ValueError: + continue + key = real_path.as_posix() + if key in seen: + continue + seen.add(key) + out.append(key) + return real_root_str, out + + async def _fetch_bytes(self, full_path: str, max_bytes: int) -> tuple[bytes, int]: + """Fetcher contract for shared collection helpers. + + Reads up to ``max_bytes`` from ``full_path`` and reports the + on-disk size so the helpers can decide truncation flags without + needing a second ``stat`` call. + + On read failure this raises and lets + :meth:`_build_code_files` / :meth:`_build_manifest_output` + apply their shared ``application/octet-stream`` sentinel — the + pre-refactor ``_read_limited`` returned that MIME explicitly for + unreadable files, and we preserve that design intent by routing + through the shared helper's except branch instead of swallowing + the error here (which would pass an empty payload through the + happy-path MIME sniffer and mis-label an unreadable ``foo.json`` + as ``application/json``). + """ + path = Path(full_path) + try: + raw_size = path.stat().st_size + except OSError: + raw_size = 0 + data = path.read_bytes()[:max_bytes] + return data, max(raw_size, len(data)) + + @override + async def stage_inputs( + self, + ws: WorkspaceInfo, + specs: List[WorkspaceInputSpec], + ctx: Optional[InvocationContext] = None, + ) -> None: + """ + Map external inputs into the workspace. + + Args: + ws: Target workspace + specs: Input specifications + """ + ensure_layout(ws.path) + md = load_metadata(ws.path) + for spec in specs: + mode = (spec.mode or "copy").lower().strip() + dst = spec.dst + if not dst or not dst.strip(): + base = self._input_default_name(spec.src) + dst = Path(DIR_WORK) / "inputs" / base + else: + dst = Path(dst) + + resolved = "" + ver = None + + if spec.src.startswith("artifact://"): + # Handle artifact inputs + name = spec.src[len("artifact://"):] + resolved, ver = parse_artifact_ref(name) + content, ver = await load_artifact_helper(ctx, resolved, ver) + self._write_file_safe( + ws.path, WorkspacePutFileInfo( + path=dst.as_posix(), + content=content, + mode=DEFAULT_FILE_MODE, + )) + elif spec.src.startswith("host://"): + # Handle host inputs + host_path = spec.src[len("host://"):] + resolved = host_path + self._put_directory(ws, host_path, dst.as_posix(), mode=mode) + elif spec.src.startswith("workspace://"): + # Handle workspace inputs + rel = spec.src[len("workspace://"):] + src = path_join(ws.path, rel) + resolved = rel + self._put_directory(ws, src, dst.as_posix(), mode=mode) + elif spec.src.startswith("skill://"): + # Handle skill inputs + rest = spec.src[len("skill://"):] + src_base = Path(ws.path) / DIR_SKILLS + src = path_join(src_base.as_posix(), rest) + resolved = src + self._put_directory(ws, src, dst.as_posix(), mode=mode) + else: + raise ValueError(f"unsupported input: {spec.src}") + + # Record input + md.inputs.append( + InputRecordMeta( + src=spec.src, + dst=dst.as_posix(), + resolved=resolved, + version=ver, + mode=mode, + timestamp=datetime.now(), + )) + + save_metadata(Path(ws.path), md) + + def _input_default_name( + self, + src: str, + ) -> str: + """Generate default input name from path.""" + # Strip scheme and keep tail element as default name + i = src.rfind("/") + if i >= 0 and i + 1 < len(src): + return src[i + 1:] + return src + + def _write_file_safe( + self, + root: str, + file: WorkspacePutFileInfo, + ) -> None: + """Safely write a file within workspace boundaries.""" + if not file.path: + raise ValueError("empty file path") + + dst = Path(path_join(root, file.path)) + + # Ensure inside root + try: + dst.relative_to(Path(root)) + except ValueError: + raise ValueError(f"path escapes workspace: {file.path}") + + dst.parent.mkdir(parents=True, exist_ok=True) + + mode = file.mode or DEFAULT_FILE_MODE + dst.write_bytes(file.content or b"") + dst.chmod(mode) + + @override + async def collect_outputs(self, + ws: WorkspaceInfo, + spec: WorkspaceOutputSpec, + ctx: Optional[InvocationContext] = None) -> ManifestOutput: + """Collect outputs from the workspace. + + Implements the declarative collector with limits, inline and + save options, records an :class:`OutputRecordMeta` entry in the + workspace metadata. + + Args: + ctx: Context for the operation + ws: Target workspace + spec: Output collection specification + + Returns: + Output manifest with collected files + """ + ensure_layout(ws.path) + + real_root, matches = self._enumerate_local_matches(ws.path, normalize_globs(spec.globs)) + out, saved_names, saved_vers = await self._build_manifest_output( + real_root, + spec, + matches, + self._fetch_bytes, + ctx, + ) + + # Record output in workspace metadata (local-only bookkeeping). + md = load_metadata(ws.path) + md.outputs.append( + OutputRecordMeta( + globs=spec.globs, + saved_as=saved_names, + versions=saved_vers, + limits_hit=out.limits_hit, + timestamp=datetime.now(), + )) + save_metadata(ws.path, md) + + return out + + +class LocalProgramRunner(BaseProgramRunner): + """Local program runner for executing commands in skill workspaces.""" + + def __init__( + self, + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False, + ): + super().__init__(provider=provider, enable_provider_env=enable_provider_env) + + def _build_program_env(self, ws: WorkspaceInfo, spec: WorkspaceRunProgramSpec) -> dict[str, str]: + env = os.environ.copy() + user_env = dict(spec.env or {}) + wr_path = Path(ws.path) + ensure_layout(wr_path) + run_dir = wr_path / DIR_RUNS / f"run_{datetime.now().strftime('%Y%m%dT%H%M%S.%f')}" + run_dir.mkdir(parents=True, exist_ok=True) + + base_env = { + WORKSPACE_ENV_DIR_KEY: ws.path, + ENV_SKILLS_DIR: str(Path(ws.path) / DIR_SKILLS), + ENV_WORK_DIR: str(Path(ws.path) / DIR_WORK), + ENV_OUTPUT_DIR: str(Path(ws.path) / DIR_OUT), + ENV_RUN_DIR: str(run_dir), + } + for key, value in base_env.items(): + if key not in user_env: + env[key] = value + if user_env: + env.update(user_env) + return env + + @override + async def run_program(self, + ws: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ctx: Optional[InvocationContext] = None) -> WorkspaceRunResult: + """Run a program in the workspace.""" + """ + Run a command inside the workspace. + + Args: + ctx: Context for the operation + ws: Target workspace + spec: Program execution specification + + Returns: + Execution result + """ + spec = self._apply_provider_env(spec, ctx) + # Resolve cwd under workspace + cwd = Path(path_join(ws.path, spec.cwd)) + cwd.mkdir(parents=True, exist_ok=True) + + timeout = spec.timeout or float(DEFAULT_TIMEOUT_SEC) + env = self._build_program_env(ws, spec) + + # Prepare command + cmd_args = [spec.cmd] + (spec.args or []) + + stdin_data = spec.stdin.encode('utf-8') if spec.stdin else None + start_time = time.time() + result = await async_execute_command(work_dir=cwd, + cmd_args=cmd_args, + input=stdin_data, + env=env, + timeout=timeout) + return WorkspaceRunResult(stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + duration=time.time() - start_time, + timed_out=result.is_timeout) + + async def start_program( + self, + ctx: Optional[InvocationContext], + ws: WorkspaceInfo, + spec: WorkspaceRunProgramSpec, + ) -> BaseProgramSession: + """Start an interactive program session in workspace.""" + if spec.tty and sys.platform == "win32": + raise ValueError("interactive tty is not supported on windows") + + spec = self._apply_provider_env(spec, ctx) + cwd = Path(path_join(ws.path, spec.cwd)) + cwd.mkdir(parents=True, exist_ok=True) + env = self._build_program_env(ws, spec) + timeout = spec.timeout or float(DEFAULT_TIMEOUT_SEC) + + cmd_args = [spec.cmd] + (spec.args or []) + if spec.tty: + master_fd, slave_fd = pty.openpty() + try: + process = await asyncio.create_subprocess_exec( + *cmd_args, + cwd=str(cwd), + env=env, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + preexec_fn=os.setsid, + ) + except Exception: + os.close(master_fd) + os.close(slave_fd) + raise + finally: + try: + os.close(slave_fd) + except OSError: + pass + session = LocalProgramSession(process, master_fd=master_fd) + else: + process = await asyncio.create_subprocess_exec( + *cmd_args, + cwd=str(cwd), + env=env, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + session = LocalProgramSession(process) + if timeout > 0: + asyncio.create_task(session.enforce_timeout(float(timeout))) + if spec.stdin: + await session.write(spec.stdin, newline=False) + return session + + +class LocalWorkspaceRuntime(BaseWorkspaceRuntime): + """Local workspace for executing commands in skill workspaces.""" + + def __init__(self, + work_root: str = '', + read_only_staged_skill: bool = False, + auto_inputs: bool = True, + inputs_host_base: str = "", + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False): + self._fs = LocalWorkspaceFS(read_only_staged_skill) + self._runner = LocalProgramRunner(provider=provider, enable_provider_env=enable_provider_env) + self._manager = LocalWorkspaceManager(work_root, auto_inputs, inputs_host_base, self._fs) + + @override + def manager(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceManager: + """Get the workspace manager.""" + return self._manager + + @override + def fs(self, ctx: Optional[InvocationContext] = None) -> BaseWorkspaceFS: + """Get the workspace file system.""" + return self._fs + + @override + def runner(self, ctx: Optional[InvocationContext] = None) -> BaseProgramRunner: + """Get the program runner.""" + return self._runner + + @override + def describe(self, ctx: Optional[InvocationContext] = None) -> WorkspaceCapabilities: + """Get the workspace capabilities.""" + return WorkspaceCapabilities( + isolation="local", + network_allowed=True, + read_only_mount=True, + streaming=True, + ) + + +def create_local_workspace_runtime(work_root: str = '', + read_only_staged_skill: bool = False, + auto_inputs: bool = True, + inputs_host_base: str = "", + provider: Optional[RunEnvProvider] = None, + enable_provider_env: bool = False) -> LocalWorkspaceRuntime: + """Create a new local workspace runtime.""" + return LocalWorkspaceRuntime(work_root, read_only_staged_skill, auto_inputs, inputs_host_base, provider, + enable_provider_env) diff --git a/trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py b/trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py new file mode 100644 index 000000000..bf8f1a7c1 --- /dev/null +++ b/trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py @@ -0,0 +1,212 @@ +# 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. +"""Unsafe local code executor for TRPC Agent framework. + +This module provides a code executor that unsafely executes code in the current local context. +This executor is not recommended for production use due to security concerns. +""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path +from typing_extensions import override + +from pydantic import Field +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.utils import async_execute_command + +from .._base_code_executor import BaseCodeExecutor +from .._types import CodeBlock +from .._types import CodeExecutionInput +from .._types import CodeExecutionResult +from .._types import create_code_execution_result + + +class UnsafeLocalCodeExecutor(BaseCodeExecutor): + """A code executor that unsafely executes code in the current local context. + + WARNING: This executor is not recommended for production use due to security concerns. + It executes code in the current process context without any Sandbox. + """ + + # Overrides the BaseCodeExecutor attribute: this executor cannot be stateful. + stateful: bool = Field(default=False, frozen=True, exclude=True) + + # Overrides the BaseCodeExecutor attribute: this executor cannot optimize_data_file. + optimize_data_file: bool = Field(default=False, frozen=True, exclude=True) + + work_dir: str = Field(default="", description="The working directory for the code execution.") + + timeout: float = Field(default=0, description="The timeout seconds for the code execution.") + + clean_temp_files: bool = Field(default=True, + description="Whether to clean temporary files after the code execution.") + + def __init__(self, **data): + """Initialize the UnsafeLocalCodeExecutor.""" + if "stateful" in data and data["stateful"]: + raise ValueError("Cannot set `stateful=True` in UnsafeLocalCodeExecutor.") + if "optimize_data_file" in data and data["optimize_data_file"]: + raise ValueError("Cannot set `optimize_data_file=True` in UnsafeLocalCodeExecutor.") + super().__init__(**data) + + @override + async def execute_code(self, invocation_context: InvocationContext, + input_data: CodeExecutionInput) -> CodeExecutionResult: + """Execute code blocks and return combined output. + + Args: + invocation_context: The invocation context of the code execution. + input_data: Code execution input + + Returns: + CodeExecutionResult with combined output + """ + output_parts = [] + error_parts = [] + if not input_data.code_blocks and input_data.code: + # If no code blocks are provided, use the code as a single code block. + input_data.code_blocks = [CodeBlock(code=input_data.code, language="python")] + + # Determine working directory + work_dir, should_cleanup = self._prepare_work_dir(input_data.execution_id) + + try: + # Execute each code block + for i, block in enumerate(input_data.code_blocks): + try: + block_output = await self._execute_code_block(work_dir, block, i) + if block_output: + output_parts.append(block_output) + except Exception as ex: # pylint: disable=broad-except + error_parts.append(f"Execution block {i} failed: {ex}") + finally: + # Cleanup if needed + if should_cleanup: + shutil.rmtree(work_dir, ignore_errors=True) + + return create_code_execution_result(stdout="\n".join(output_parts) if output_parts else "", + stderr="\n".join(error_parts) if error_parts else "") + + def _prepare_work_dir(self, execution_id: str) -> tuple[Path, bool]: + """Prepare working directory for execution. + + Args: + execution_id: Unique execution identifier + + Returns: + Tuple of (work_dir_path, should_cleanup) + + Raises: + OSError: If directory creation fails + """ + if self.work_dir: + # Use configured work directory + work_path = Path(self.work_dir) + if not work_path.is_absolute(): + work_path = work_path.resolve() + + work_path.mkdir(parents=True, exist_ok=True) + return work_path, False + else: + # Create temporary directory + temp_dir = tempfile.mkdtemp(prefix=f"codeexec_{execution_id}_") + return Path(temp_dir), self.clean_temp_files + + async def _execute_code_block(self, work_dir: Path, block: CodeBlock, block_index: int) -> str: + """Execute a single code block. + + Args: + work_dir: Working directory + block: Code block to execute + block_index: Index of the block + + Returns: + Output from the execution + + Raises: + ValueError: If language is unsupported + subprocess.TimeoutExpired: If execution times out + subprocess.CalledProcessError: If execution fails + """ + # Prepare code file + file_path = self._prepare_code_file(work_dir, block, block_index) + + # Build command arguments + cmd_args = self._build_command_args(block.language, file_path) + + # Execute command + result = await async_execute_command(work_dir=work_dir, cmd_args=cmd_args, timeout=self.timeout) + if result.exit_code != 0 or result.is_timeout: + error_msg = result.stderr if result.stderr else f"Command failed with return code {result.exit_code}" + raise RuntimeError(error_msg) + return result.stdout + + def _prepare_code_file(self, work_dir: Path, block: CodeBlock, block_index: int) -> Path: + """Write code to a temporary file. + + Args: + work_dir: Working directory + block: Code block + block_index: Index of the block + + Returns: + Path to the created file + + Raises: + ValueError: If language is unsupported + OSError: If file write fails + """ + language = block.language.lower() + + # Determine file extension + if language in ("python", "py", "python3"): + ext = ".py" + file_mode = 0o644 + elif language in ("bash", "sh"): + ext = ".sh" + file_mode = 0o755 + else: + raise ValueError(f"unsupported language: {block.language}") + + # Create file path + file_name = f"code_{block_index}{ext}" + file_path = work_dir / file_name + + # Prepare content + content = block.code.strip() + + # For Python, ensure newline at end if no print statements + if language in ("python", "py", "python3"): + if "print(" not in content and "sys.stdout.write(" not in content: + content += "\n" + + # Write file + file_path.write_text(content, encoding="utf-8") + file_path.chmod(file_mode) + + return file_path + + def _build_command_args(self, language: str, file_path: Path) -> list[str]: + """Build command arguments for executing the code file. + + Args: + language: Programming language + file_path: Path to the code file + + Returns: + List of command arguments, or empty list if unsupported + """ + language = language.lower() + + if language in ("python", "py", "python3"): + return ["python3", str(file_path)] + elif language in ("bash", "sh"): + return ["bash", str(file_path)] + else: + raise ValueError(f"unsupported language: {language}") diff --git a/trpc_agent_sdk/code_executors/utils/__init__.py b/trpc_agent_sdk/code_executors/utils/__init__.py new file mode 100644 index 000000000..f203777ba --- /dev/null +++ b/trpc_agent_sdk/code_executors/utils/__init__.py @@ -0,0 +1,52 @@ +# 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. +"""Code execution utilities for TRPC Agent framework. + +This module provides utility functions for processing code blocks, +extracting code from responses, and handling code execution results. +""" + +from ._code_execution import CodeExecutionUtils +from ._files import collect_files_with_glob +from ._files import copy_dir +from ._files import copy_path +from ._files import detect_content_type +from ._files import get_rel_path +from ._files import make_symlink +from ._files import make_tree_read_only +from ._files import path_join +from ._meta import InputRecordMeta +from ._meta import OutputRecordMeta +from ._meta import SkillMeta +from ._meta import WorkspaceMetadata +from ._meta import dir_digest +from ._meta import ensure_layout +from ._meta import load_metadata +from ._meta import save_metadata +from ._workspace import build_block_spec +from ._workspace import normalize_globs + +__all__ = [ + "CodeExecutionUtils", + "collect_files_with_glob", + "copy_dir", + "copy_path", + "detect_content_type", + "get_rel_path", + "make_symlink", + "make_tree_read_only", + "path_join", + "InputRecordMeta", + "OutputRecordMeta", + "SkillMeta", + "WorkspaceMetadata", + "dir_digest", + "ensure_layout", + "load_metadata", + "save_metadata", + "build_block_spec", + "normalize_globs", +] diff --git a/trpc_agent_sdk/code_executors/utils/_code_execution.py b/trpc_agent_sdk/code_executors/utils/_code_execution.py new file mode 100644 index 000000000..f9f75a401 --- /dev/null +++ b/trpc_agent_sdk/code_executors/utils/_code_execution.py @@ -0,0 +1,267 @@ +# 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. +"""Code execution utilities for TRPC Agent framework. + +This module provides utility functions for processing code blocks, +extracting code from responses, and handling code execution results. +""" +import base64 +import binascii +import re +from typing import Any + +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from .._types import CodeBlock +from .._types import CodeBlockDelimiter +from .._types import CodeExecutionResult + + +class CodeExecutionUtils: + """Utility functions for code execution.""" + + @classmethod + def _is_ignored_code_block(cls, code: str, ignore_codes: list[str]) -> bool: + """Return True when code block first line is in ignore list.""" + if not code: + return False + lines = code.splitlines() + if not lines: + return False + first_line = lines[0].strip() + if not first_line: + return False + ignore_set = {item.strip() for item in ignore_codes if item and item.strip()} + return first_line in ignore_set + + @classmethod + def prepare_globals(cls, code: str, globals_: dict[str, Any]) -> None: + """Prepare globals for code execution, injecting __name__ if needed.""" + if re.search(r"if\s+__name__\s*==\s*['\"]__main__['\"]", code): + globals_["__name__"] = "__main__" + + @classmethod + def extract_fence_language(cls, text: str) -> str: + """Extract the language from the text.""" + pattern = r'(?m)^[ \t]*(?:`{3,}|~{3,})[ \t]*([^\s`~]+)' + m = re.search(pattern, text) + return m.group(1) if m else "" + + @classmethod + def get_encoded_file_content(cls, data: bytes) -> bytes: + """Gets the file content as a base64-encoded bytes. + + Args: + data: The file content bytes. + + Returns: + The file content as a base64-encoded bytes. + """ + + def _is_base64_encoded(data: bytes) -> bool: + try: + return base64.b64encode(base64.b64decode(data)) == data + except binascii.Error: + return False + + return data if _is_base64_encoded(data) else base64.b64encode(data) + + @classmethod + def extract_code_and_truncate_content( + cls, + content: Content, + code_block_delimiters: list[CodeBlockDelimiter], + ignore_codes: list[str] = None, + ) -> list[CodeBlock]: + """Extracts all code blocks from the content and reconstructs content.parts. + + This function extracts all code blocks from the content and rebuilds content.parts + to contain alternating text and executable code parts in their original order. + + Args: + content: The mutable content to extract the code from. + code_block_delimiters: The list of the enclosing delimiters to identify + the code blocks. + ignore_codes: The list of codes to ignore. + + Returns: + The first code block if found; otherwise, None. + """ + ignore_codes = ignore_codes or [] + code_blocks = [] + if not content or not content.parts: + return code_blocks + + # Extract the code from the executable code parts if there are no associated + # code execution result parts. + total_len = len(content.parts) + for idx, part in enumerate(content.parts): + if part.executable_code: + code_str = part.executable_code.code or "" + if cls._is_ignored_code_block(code_str, ignore_codes): + continue + if idx < total_len - 1 and not content.parts[idx + 1].code_execution_result: + code_blocks.append(CodeBlock(code=code_str, language=part.executable_code.language)) + if idx == total_len - 1: + code_blocks.append(CodeBlock(code=code_str, language=part.executable_code.language)) + # If there are code blocks, return them. + if code_blocks: + return code_blocks + + # Extract the code from the text parts. + text_parts = [p for p in content.parts if p.text] + if not text_parts: + return code_blocks + + response_text = '\n'.join([p.text for p in text_parts]) + + # Build regex pattern to match all code blocks + leading_delimiter_pattern = '|'.join(re.escape(d.start) for d in code_block_delimiters) + trailing_delimiter_pattern = '|'.join(re.escape(d.end) for d in code_block_delimiters) + + # Pattern to capture: delimiter start, optional language identifier, code content, and delimiter end + # The start delimiter may already include the language (e.g., "```python\n") + # So we need to match the start delimiter, then capture everything until the end delimiter + pattern = re.compile( + rf'({leading_delimiter_pattern})(.*?)({trailing_delimiter_pattern})', + re.DOTALL, + ) + + # Find all code blocks and their positions + matches = list(pattern.finditer(response_text)) + if not matches: + return code_blocks + + # Rebuild content.parts with alternating text and code blocks + new_parts = [] + last_end = 0 + first_code = None + + for match in matches: + # Add text before this code block (if any) + text_before = response_text[last_end:match.start()].strip() + if text_before: + new_parts.append(Part(text=text_before)) + + # Extract the matched parts + start_delimiter = match.group(1) # e.g., "```python\n" + code_content = match.group(2) # The code content between delimiters + + # Extract language from start delimiter if present + # Try to match language from patterns like "```python\n" or "```tool_code\n" + lang_match = re.search(r'```(\w+)', start_delimiter) + language = lang_match.group(1) if lang_match else "" + + # Extract code content, removing leading/trailing whitespace and newlines + code_str = code_content.strip() + if cls._is_ignored_code_block(code_str, ignore_codes): + last_end = match.end() + continue + + # Store first code block for return value + if first_code is None: + first_code = code_str + + # Determine language for executable code part + # Default to PYTHON if not specified or not recognized + exec_language = 'PYTHON' # Default value + if language: + lang_lower = language.lower() + if lang_lower in ('python', 'py', 'python3', 'tool_code'): + exec_language = 'PYTHON' + elif lang_lower in ('bash', 'sh', 'shell'): + exec_language = 'BASH' # Keep as PYTHON since API may not support BASH + else: + exec_language = 'PYTHON' + # Add more language mappings as needed + code_blocks.append(CodeBlock(code=code_str, language=exec_language)) + # Add executable code part + new_parts.append(Part.from_executable_code( + code=code_str, + language='PYTHON', + )) + + last_end = match.end() + + # Add any remaining text after the last code block + text_after = response_text[last_end:].strip() + if text_after: + new_parts.append(Part(text=text_after)) + + # Replace content.parts with new parts + content.parts = new_parts + + return code_blocks + + @classmethod + def build_executable_code_part(cls, code: str) -> Part: + """Builds an executable code part with code string. + + Args: + cls: The class instance. + code: The code string. + + Returns: + The constructed executable code part. + """ + return Part.from_executable_code( + code=code, + language='PYTHON', + ) + + @classmethod + def build_code_execution_result_part( + cls, + code_execution_result: CodeExecutionResult, + ) -> Part: + """Builds the code execution result part from the code execution result. + + Args: + cls: The class instance. + code_execution_result: The code execution result. + + Returns: + The constructed code execution result part. + """ + return Part.from_code_execution_result( + outcome=code_execution_result.outcome, + output=code_execution_result.output if code_execution_result.output else '', + ) + + @classmethod + def convert_code_execution_parts( + cls, + content: Content, + code_block_delimiter: CodeBlockDelimiter, + execution_result_delimiters: CodeBlockDelimiter, + ) -> None: + """Converts the code execution parts to text parts in a Content. + + Args: + cls: The class instance. + content: The mutable content to convert the code execution parts to text + parts. + code_block_delimiter: The delimiter to format the code block. + execution_result_delimiters: The delimiter to format the code execution + result. + """ + if not content.parts: + return + + # Handle the conversion of trailing executable code parts. + if content.parts[-1].executable_code: + content.parts[-1] = Part(text=(f"{code_block_delimiter.start}" + f"{content.parts[-1].executable_code.code}" + f"{code_block_delimiter.end}")) + # Handle the conversion of trailing code execution result parts. + # Skip if the Content has multiple parts, which means the Content is + # likely generated by the model. + elif len(content.parts) == 1 and content.parts[-1].code_execution_result: + content.parts[-1] = Part(text=(f"{execution_result_delimiters.start}" + f"{content.parts[-1].code_execution_result.output}" + f"{execution_result_delimiters.end}")) + content.role = 'user' diff --git a/trpc_agent_sdk/code_executors/utils/_files.py b/trpc_agent_sdk/code_executors/utils/_files.py new file mode 100644 index 000000000..af79ab66c --- /dev/null +++ b/trpc_agent_sdk/code_executors/utils/_files.py @@ -0,0 +1,276 @@ +# 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. +"""File utilities for TRPC Agent framework. + +This module provides utility functions for file operations. +""" + +from __future__ import annotations + +import glob +import mimetypes +import os +import shutil +from pathlib import Path +from typing import Optional + +try: + import magic + HAS_MAGIC = True +except ImportError: + HAS_MAGIC = False + + +def path_join(base: str, path: str) -> str: + """Join a base path and a path. + + Args: + base: Base path + path: Path + + Returns: + The joined path. + """ + return os.path.join(base, os.path.normpath(path)) + + +def copy_dir(src: Path, dst: Path) -> None: + """Recursively copy a directory tree from src to dst. + + This function replicates the Go copyDir behavior: + - Creates destination directory if it doesn't exist + - Walks through source directory tree + - Copies files preserving permissions + - Creates subdirectories as needed + + Args: + src: Source directory path + dst: Destination directory path + + Raises: + OSError: If directory creation or file operations fail + """ + # Use shutil.copytree for efficient directory copying + # dirs_exist_ok=True allows copying into existing directory + shutil.copytree(src, dst, dirs_exist_ok=True, symlinks=False) + + +def make_tree_read_only(root: Path) -> None: + """Remove write bits from the entire directory tree. + + This function replicates the Go makeTreeReadOnly behavior: + - Walks through the directory tree + - Removes write permissions (owner/group/other) from all files and directories + - Preserves read and execute permissions + + Args: + root: Root directory path to make read-only + + Raises: + OSError: If permission changes fail + """ + root_path = Path(root) + + # Walk through all files and directories + for item in root_path.rglob('*'): + try: + # Get current permissions + current_mode = item.stat().st_mode + # Clear write bits (0o222 = owner/group/other write) + new_mode = current_mode & ~0o222 + item.chmod(new_mode) + except OSError: + pass # Continue on error + + # Process the root directory itself + try: + current_mode = root_path.stat().st_mode + new_mode = current_mode & ~0o222 + root_path.chmod(new_mode) + except OSError: + pass + + +def copy_path(src: str, dst: str) -> None: + """Copy a file or directory from src to dst. + + Args: + src: Source path (file or directory) + dst: Destination path + + Raises: + OSError: If copy operations fail + """ + src_path = Path(src) + dst_path = Path(dst) + + if src_path.is_dir(): + # Source is a directory + copy_dir(src_path, dst_path) + else: + # Source is a file + # Ensure destination directory exists + dst_path.parent.mkdir(parents=True, mode=0o755, exist_ok=True) + + # Read and write file using Path + data = src_path.read_bytes() + dst_path.write_bytes(data) + + # Preserve file permissions + dst_path.chmod(src_path.stat().st_mode) + + +def make_symlink(root: str, dst: str, target: str) -> None: + """Create a symbolic link in the workspace. + + Args: + root: Workspace root directory + dst: Destination path for the symlink + target: Target path for the symlink (absolute path) + + Raises: + OSError: If symlink creation fails + """ + dst: Path = Path(path_join(root, dst)) + + # Ensure parent directory exists + dst.parent.mkdir(parents=True, mode=0o755, exist_ok=True) + + # Remove existing path if present + if dst.exists() or dst.is_symlink(): + if dst.is_dir() and not dst.is_symlink(): + shutil.rmtree(dst.as_posix()) + else: + dst.unlink() + + # Create symlink + dst.symlink_to(target) + + +def collect_files_with_glob(ws_path: str, glob_pattern: str) -> list[str]: + """ + Collect files matching a glob pattern within a workspace. + + This function exactly mimics the Go code behavior: + ```go + abs := filepath.Join(ws.Path, g) + pattern := strings.TrimPrefix(abs, "/") + matches, err := ds.Glob(os.DirFS("/"), pattern) + ``` + + Args: + ws_path: Workspace root path (e.g., "/tmp/workspace") + glob_pattern: Glob pattern relative to workspace (e.g., "out/*.txt", "**/*.py") + + Returns: + List of matched file paths (absolute paths), sorted alphabetically + + Raises: + Exception: If glob matching fails + + Examples: + >>> # Example 1: Simple glob pattern + >>> ws_path = "/tmp/workspace" + >>> pattern = "out/*.txt" + >>> matches = collect_files_by_glob(ws_path, pattern) + >>> # Returns: ['/tmp/workspace/out/file1.txt', '/tmp/workspace/out/file2.txt'] + + >>> # Example 2: Doublestar pattern + >>> ws_path = "/home/user/project" + >>> pattern = "**/*.py" + >>> matches = collect_files_by_glob(ws_path, pattern) + >>> # Returns: ['/home/user/project/src/main.py', '/home/user/project/tests/test.py'] + + >>> # Example 3: Nested directory pattern + >>> ws_path = "/var/data" + >>> pattern = "logs/**/error.log" + >>> matches = collect_files_by_glob(ws_path, pattern) + >>> # Returns: ['/var/data/logs/2024/01/error.log', '/var/data/logs/2024/02/error.log'] + """ + # Step 1: Join workspace path with glob pattern (equivalent to filepath.Join) + # Using Path for cross-platform compatibility + abs_path = str(Path(ws_path) / glob_pattern) + + # Step 2: Remove leading "/" if present (equivalent to strings.TrimPrefix) + pattern = abs_path.lstrip("/") + + # Step 3: Perform glob matching from root "/" (equivalent to ds.Glob(os.DirFS("/"), pattern)) + # Prepend "/" back to search from root + search_pattern = "/" + pattern + + try: + # Use glob with recursive=True for doublestar (**) support + matches = glob.glob(search_pattern, recursive=True) + + # Filter out directories, keep only files (matching Go's behavior) + file_matches = [m for m in matches if os.path.isfile(m)] + + return sorted(file_matches) # Sort for consistent output + except Exception as ex: # pylint: disable=broad-except + raise Exception(f"Glob matching failed for pattern '{search_pattern}': {ex}") + + +def detect_content_type(filename: Path, data: bytes) -> str: + """Detect content type from filename and data. + + Args: + filename: Path to the file + data: Data of the file + + Returns: + The content type of the file. + """ + # try to guess from filename + mime_type, _ = mimetypes.guess_type(str(filename)) + if mime_type: + return mime_type + + # filename guess failed, use magic to guess + if HAS_MAGIC: + return magic.from_buffer(data, mime=True) + + # magic guess failed, use simple content-based detection + if data.startswith(b'\x89PNG\r\n\x1a\n'): + return 'image/png' + if data.startswith(b'\xff\xd8\xff'): + return 'image/jpeg' + if data.startswith(b'%PDF'): + return 'application/pdf' + if data.startswith(b'PK'): + return 'application/zip' + if data.startswith(b' Optional[Path]: + """Get the relative path from base to path. + + Args: + base: Base path + path: Path + + Returns: + The relative path. + """ + if isinstance(base, str): + base = Path(base) + if isinstance(path, str): + path = Path(path) + try: + return Path(path).relative_to(Path(base)) + except ValueError: + return None diff --git a/trpc_agent_sdk/code_executors/utils/_meta.py b/trpc_agent_sdk/code_executors/utils/_meta.py new file mode 100644 index 000000000..933a4ba36 --- /dev/null +++ b/trpc_agent_sdk/code_executors/utils/_meta.py @@ -0,0 +1,271 @@ +# 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. +""" +Workspace metadata helpers and constants. + +This module holds workspace metadata helpers and constants for managing +workspace structure, skill staging, and input/output tracking. +""" + +import hashlib +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + +from .._constants import DIR_OUT +from .._constants import DIR_RUNS +from .._constants import DIR_SKILLS +from .._constants import DIR_WORK +from .._constants import META_FILE_NAME + + +class SkillMeta(BaseModel): + """ + Records a staged skill snapshot. + """ + + name: Optional[str] = None + rel_path: Optional[str] = None + digest: Optional[str] = None + mounted: Optional[bool] = None + staged_at: Optional[datetime] = Field(default=None, alias="staged_at") + + +class InputRecordMeta(BaseModel): + """ + Tracks a staged input resolution. + """ + + src: Optional[str] = Field(default=None, alias="from") + dst: Optional[str] = None + resolved: Optional[str] = None + version: Optional[int] = None + mode: Optional[str] = None + timestamp: Optional[datetime] = Field(default=None, alias="ts") + + +class OutputRecordMeta(BaseModel): + """ + Tracks an output collection run. + """ + + globs: list[str] = Field(default_factory=list) + saved_as: list[str] = Field(default_factory=list) + versions: list[int] = Field(default_factory=list) + limits_hit: Optional[bool] = None + timestamp: Optional[datetime] = Field(default=None, alias="ts") + + +class WorkspaceMetadata(BaseModel): + """ + Describes staged skills and recent activity. + """ + + version: Optional[int] = None + created_at: Optional[datetime] = Field(default=None, alias="created_at") + updated_at: Optional[datetime] = Field(default=None, alias="updated_at") + last_access: Optional[datetime] = Field(default=None, alias="last_access") + skills: dict[str, SkillMeta] = Field(default_factory=dict) + inputs: list[InputRecordMeta] = Field(default_factory=list) + outputs: list[OutputRecordMeta] = Field(default_factory=list) + + +def ensure_layout(root: Path | str) -> dict[str, Path]: + """ + Create standard workspace subdirectories and a metadata file when absent. + + Returns full paths for convenience. + + Args: + root: Workspace root directory path + + Returns: + Dictionary mapping directory names to full paths + + Raises: + OSError: If directory creation or file operations fail + """ + if isinstance(root, str): + root = Path(root) + paths = { + DIR_SKILLS: root / DIR_SKILLS, + DIR_WORK: root / DIR_WORK, + DIR_RUNS: root / DIR_RUNS, + DIR_OUT: root / DIR_OUT, + } + + for p in paths.values(): + Path(p).mkdir(parents=True, exist_ok=True) + + # Host/user files are staged under work/inputs before skill_load links them. + (root / DIR_WORK / "inputs").mkdir(parents=True, exist_ok=True) + + # Initialize metadata if missing + meta_file = root / META_FILE_NAME + if not meta_file.exists(): + now = datetime.now() + md = WorkspaceMetadata( + version=1, + created_at=now, + updated_at=now, + last_access=now, + skills={}, + ) + save_metadata(root, md) + + return paths + + +def load_metadata(root: Path | str) -> WorkspaceMetadata: + """ + Load metadata.json from workspace root. + + When missing, an empty metadata with defaults is returned without error. + + Args: + root: Workspace root directory path + + Returns: + WorkspaceMetadata object + + Raises: + OSError: If file read fails (except for not found) + json.JSONDecodeError: If JSON parsing fails + """ + if isinstance(root, str): + root = Path(root) + meta_file = root / META_FILE_NAME + + if not meta_file.exists(): + now = datetime.now() + return WorkspaceMetadata( + version=1, + created_at=now, + updated_at=now, + last_access=now, + skills={}, + ) + + content = meta_file.read_text(encoding="utf-8") + data = json.loads(content) + + # Convert datetime strings to datetime objects + if "created_at" in data and data["created_at"]: + data["created_at"] = datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) + if "updated_at" in data and data["updated_at"]: + data["updated_at"] = datetime.fromisoformat(data["updated_at"].replace("Z", "+00:00")) + if "last_access" in data and data["last_access"]: + data["last_access"] = datetime.fromisoformat(data["last_access"].replace("Z", "+00:00")) + + # Convert skills + if "skills" in data and data["skills"]: + for skill_data in data["skills"].values(): + if skill_data.get("staged_at", None): + skill_data["staged_at"] = datetime.fromisoformat(skill_data["staged_at"].replace("Z", "+00:00")) + + # Convert inputs + if "inputs" in data and data["inputs"]: + for input_rec in data["inputs"]: + if "ts" in input_rec and input_rec["ts"]: + input_rec["ts"] = datetime.fromisoformat(input_rec["ts"].replace("Z", "+00:00")) + + # Convert outputs + if "outputs" in data and data["outputs"]: + for output_rec in data["outputs"]: + if "ts" in output_rec and output_rec["ts"]: + output_rec["ts"] = datetime.fromisoformat(output_rec["ts"].replace("Z", "+00:00")) + + return WorkspaceMetadata(**data) + + +def save_metadata( + root: Path | str, + md: WorkspaceMetadata, +) -> None: + """ + Write metadata.json to the workspace root. + + Args: + root: Workspace root directory path + md: WorkspaceMetadata object to save + + Raises: + OSError: If file write or rename fails + json.JSONEncodeError: If JSON encoding fails + """ + if isinstance(root, str): + root = Path(root) + md.updated_at = datetime.now() + + # Convert to dict and handle datetime serialization + data = md.model_dump(exclude_none=True, by_alias=True) + + # Recursively convert datetime objects + def convert_datetimes(d): + if isinstance(d, dict): + return {k: convert_datetimes(v) for k, v in d.items()} + elif isinstance(d, list): + return [convert_datetimes(item) for item in d] + elif isinstance(d, datetime): + return d.isoformat() + return d + + data = convert_datetimes(data) + + buf = json.dumps(data, indent=2, ensure_ascii=False) + + tmp_file = root / ".metadata.tmp" + meta_file = root / META_FILE_NAME + + tmp_file.write_text(buf, encoding="utf-8") + tmp_file.chmod(0o600) + tmp_file.rename(meta_file) + + +def dir_digest(root: Path) -> str: + """ + Compute a stable digest of a directory tree. + + Walks the tree, sorts entries, and hashes relative path and contents. + + Args: + root: Root directory path to compute digest for + + Returns: + Hexadecimal digest string + + Raises: + OSError: If directory walk or file read fails + """ + files = [] + + for file_path in root.rglob("*"): + if file_path.is_file(): + rel_path = file_path.relative_to(root) + files.append(rel_path) + + # Sort for stability + files.sort() + + h = hashlib.sha256() + + for rel_path in files: + # Normalize to slash for stability + normalized = str(rel_path).replace(os.sep, "/") + h.update(normalized.encode("utf-8")) + h.update(b"\x00") + + file_path = root / rel_path + content = file_path.read_bytes() + h.update(content) + h.update(b"\x00") + + return h.hexdigest() diff --git a/trpc_agent_sdk/code_executors/utils/_workspace.py b/trpc_agent_sdk/code_executors/utils/_workspace.py new file mode 100644 index 000000000..b56dbe0d0 --- /dev/null +++ b/trpc_agent_sdk/code_executors/utils/_workspace.py @@ -0,0 +1,167 @@ +# 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. +"""Workspace utilities for TRPC Agent framework. + +This module provides utility functions for workspace operations. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional + +from .._constants import DEFAULT_EXEC_FILE_MODE +from .._constants import DEFAULT_SCRIPT_FILE_MODE +from .._constants import DIR_OUT +from .._constants import DIR_SKILLS +from .._constants import DIR_WORK +from .._constants import NORMALIZE_GLOBS_BACKSLASH +from .._constants import NORMALIZE_GLOBS_OUT +from .._constants import NORMALIZE_GLOBS_SKILLS +from .._constants import NORMALIZE_GLOBS_SLASH +from .._constants import NORMALIZE_GLOBS_VAR_LBRACE +from .._constants import NORMALIZE_GLOBS_VAR_PREFIX +from .._constants import NORMALIZE_GLOBS_VAR_RBRACE +from .._constants import NORMALIZE_GLOBS_WORK +from .._constants import NORMALIZE_GLOBS_WORKSPACE +from .._constants import NORMALIZE_GLOBS_WORKSPACE_DIR +from .._types import CodeBlock + + +def _trim_glob_separator(s: str, ) -> str: + """ + Trim leading path separator from string. + """ + if not s: + return s + + if s.startswith(NORMALIZE_GLOBS_SLASH) or s.startswith(NORMALIZE_GLOBS_BACKSLASH): + return s[1:] + + return s + + +def _normalize_glob_tail( + tail: str, + dir_name: str, +) -> str: + """ + Normalize the tail part of a glob pattern. + """ + if not tail: + if dir_name == NORMALIZE_GLOBS_WORKSPACE_DIR: + return NORMALIZE_GLOBS_WORKSPACE_DIR + return dir_name + + r = _trim_glob_separator(tail) + + if dir_name == NORMALIZE_GLOBS_WORKSPACE_DIR: + if not r: + return NORMALIZE_GLOBS_WORKSPACE_DIR + return r + + if not r: + return dir_name + + return dir_name + NORMALIZE_GLOBS_SLASH + r + + +def _normalize_glob_prefix( + s: str, + name: str, + dir_name: str, +) -> str: + """ + Normalize a single glob prefix. + """ + brace_prefix = NORMALIZE_GLOBS_VAR_LBRACE + name + NORMALIZE_GLOBS_VAR_RBRACE + if s.startswith(brace_prefix): + return _normalize_glob_tail(s[len(brace_prefix):], dir_name) + + simple_prefix = NORMALIZE_GLOBS_VAR_PREFIX + name + if s.startswith(simple_prefix): + return _normalize_glob_tail(s[len(simple_prefix):], dir_name) + + return s + + +def build_block_spec( + idx: int, + block: CodeBlock, +) -> tuple[str, int, str, Optional[List[str]]]: + """ + Map a code block into file name, mode, command, and arguments. + + Supports Python and Bash languages. + + Args: + idx: Block index for file naming + block: Code block to process + + Returns: + Tuple of (filename, mode, command, args) + + Raises: + ValueError: If language is unsupported + """ + lang = (block.language or "").strip().lower() + + if lang in ("python", "py", "python3"): + return ( + f"code_{idx}.py", + DEFAULT_SCRIPT_FILE_MODE, + "python3", + None, + ) + elif lang in ("bash", "sh"): + return ( + f"code_{idx}.sh", + DEFAULT_EXEC_FILE_MODE, + "bash", + None, + ) + else: + raise ValueError(f"unsupported language: {block.language}") + + +def normalize_globs(patterns: List[str], ) -> List[str]: + """ + Rewrite glob patterns with environment-style prefixes. + + Converts patterns like $OUTPUT_DIR/a.txt to out/a.txt. + Understands: WORKSPACE_DIR, SKILLS_DIR, WORK_DIR, OUTPUT_DIR. + + Args: + patterns: List of glob patterns + + Returns: + List of normalized patterns + + Examples: + $OUTPUT_DIR/a.txt -> out/a.txt + ${WORK_DIR}/x/** -> work/x/** + $WORKSPACE_DIR/out -> out + """ + if not patterns: + return [] + + out = [] + for p in patterns: + s = p.strip() + if not s: + continue + + s = _normalize_glob_prefix( + s, + NORMALIZE_GLOBS_WORKSPACE, + NORMALIZE_GLOBS_WORKSPACE_DIR, + ) + s = _normalize_glob_prefix(s, NORMALIZE_GLOBS_SKILLS, DIR_SKILLS) + s = _normalize_glob_prefix(s, NORMALIZE_GLOBS_WORK, DIR_WORK) + s = _normalize_glob_prefix(s, NORMALIZE_GLOBS_OUT, DIR_OUT) + out.append(s) + + return out diff --git a/trpc_agent_sdk/common/__init__.py b/trpc_agent_sdk/common/__init__.py new file mode 100644 index 000000000..aa6652a48 --- /dev/null +++ b/trpc_agent_sdk/common/__init__.py @@ -0,0 +1,18 @@ +# 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. +""" +Common utilities for TRPC Agent. +""" + +from ._compatible import OSDetector +from ._compatible import OS_DETECTOR +from ._compatible import check_enum + +__all__ = [ + "OSDetector", + "OS_DETECTOR", + "check_enum", +] diff --git a/trpc_agent_sdk/common/_compatible.py b/trpc_agent_sdk/common/_compatible.py new file mode 100644 index 000000000..19f368176 --- /dev/null +++ b/trpc_agent_sdk/common/_compatible.py @@ -0,0 +1,85 @@ +# 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. +"""Compatible Python Version Check Module""" + +import platform +import sys +from typing import Any +from typing import Dict +from enum import Enum + +PY_310 = sys.version_info >= (3, 10) + + +def check_enum(value: Any, enum_class: type[Enum]) -> bool: + """Check if a value is a valid member of an enum class.""" + try: + return value in enum_class + except Exception: # pylint: disable=broad-except + return value in enum_class.__members__.values() + + +class OSDetector: + """OS Detector""" + + def __init__(self): + self._os_info = self._detect_os() + + def _detect_os(self) -> Dict[str, Any]: + """Detect OS information""" + return { + 'system': platform.system(), + 'platform': sys.platform, + 'release': platform.release(), + 'version': platform.version(), + 'machine': platform.machine(), + 'processor': platform.processor(), + 'architecture': platform.architecture(), + 'node': platform.node(), + 'python_version': platform.python_version(), + 'python_implementation': platform.python_implementation() + } + + @property + def is_windows(self) -> bool: + """Is Windows""" + return self._os_info['system'] == 'Windows' or self._os_info['platform'].startswith('win') + + @property + def is_macos(self) -> bool: + """Is macOS""" + return self._os_info['system'] == 'Darwin' or self._os_info['platform'].startswith('darwin') + + @property + def is_linux(self) -> bool: + """Is Linux""" + return self._os_info['system'] == 'Linux' or self._os_info['platform'].startswith('linux') + + @property + def is_unix(self) -> bool: + """Is Unix system (including macOS and Linux)""" + return self.is_macos or self.is_linux + + def get_os_name(self) -> str: + """Get OS name""" + if self.is_windows: + return 'Windows' + elif self.is_macos: + return 'macOS' + elif self.is_linux: + return 'Linux' + else: + return 'Unknown' + + def get_os_info(self) -> Dict[str, Any]: + """Get complete OS information""" + return self._os_info.copy() + + def __str__(self) -> str: + return f"OSDetector({self.get_os_name()})" + + +OS_DETECTOR = OSDetector() diff --git a/trpc_agent_sdk/common/_constants.py b/trpc_agent_sdk/common/_constants.py new file mode 100644 index 000000000..f85d3f91c --- /dev/null +++ b/trpc_agent_sdk/common/_constants.py @@ -0,0 +1,6 @@ +# 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. +"""Constants for common utilities.""" diff --git a/trpc_agent_sdk/configs/__init__.py b/trpc_agent_sdk/configs/__init__.py new file mode 100644 index 000000000..c505fbb5f --- /dev/null +++ b/trpc_agent_sdk/configs/__init__.py @@ -0,0 +1,18 @@ +# 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. +"""Configs for TRPC Agent framework.""" + +from ._model_retry_config import ExponentialBackoffConfig +from ._model_retry_config import ModelRetryConfig +from ._prompt_cache_config import PromptCacheConfig +from ._run_config import RunConfig + +__all__ = [ + "ExponentialBackoffConfig", + "ModelRetryConfig", + "PromptCacheConfig", + "RunConfig", +] diff --git a/trpc_agent_sdk/configs/_model_retry_config.py b/trpc_agent_sdk/configs/_model_retry_config.py new file mode 100644 index 000000000..a18e32e69 --- /dev/null +++ b/trpc_agent_sdk/configs/_model_retry_config.py @@ -0,0 +1,42 @@ +# 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. +"""Model retry configuration for TRPC Agent framework.""" + +from __future__ import annotations + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + + +class ExponentialBackoffConfig(BaseModel): + """Configuration for exponential retry backoff.""" + + model_config = ConfigDict(extra="forbid") + + initial_backoff: float = Field(default=1.0, ge=0.0) + """Base backoff in seconds for the first exponential retry.""" + + max_backoff: float = Field(default=10.0, ge=0.0) + """Upper bound in seconds for any single computed backoff.""" + + multiplier: float = Field(default=2.0, ge=1.0) + """Exponential growth factor per attempt.""" + + jitter: bool = True + """Whether to apply full jitter to computed backoff values.""" + + +class ModelRetryConfig(BaseModel): + """SDK-managed model retry configuration.""" + + model_config = ConfigDict(extra="forbid") + + num_retries: int = Field(default=2, ge=0) + """Retry attempts in addition to the initial call.""" + + backoff: ExponentialBackoffConfig = Field(default_factory=ExponentialBackoffConfig) + """Exponential backoff configuration used between retries.""" diff --git a/trpc_agent_sdk/configs/_prompt_cache_config.py b/trpc_agent_sdk/configs/_prompt_cache_config.py new file mode 100644 index 000000000..f116737b2 --- /dev/null +++ b/trpc_agent_sdk/configs/_prompt_cache_config.py @@ -0,0 +1,72 @@ +# 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. +"""Prompt cache configuration for TRPC Agent framework.""" + +from __future__ import annotations + +from typing import List +from typing import Literal +from typing import Optional + +from pydantic import BaseModel +from pydantic import Field + + +class PromptCacheConfig(BaseModel): + """Cross-provider prompt cache configuration. + + This is a single flat config for SDK-managed prompt cache customization. + Many providers already enable prompt caching automatically; for those + providers this config only supplies optional hints such as cache keys, + retention, or usage normalization. Fields are applied on a best-effort basis + depending on the resolved provider. Fields that do not apply to a given + provider are silently ignored (no error), so the same config "just works" + across Anthropic, OpenAI, and the LiteLLM channel. + + Default ``enabled=False`` means the SDK does not add cache-specific request + customization: it injects no ``cache_control`` and sends no + ``prompt_cache_key`` / ``prompt_cache_retention``. Provider-native automatic + prompt caching, when available, may still happen independently. + """ + + enabled: bool = False + """Master switch for SDK-managed prompt cache customization.""" + + ttl: Optional[str] = None + """Provider-specific cache lifetime hint. + + The SDK does not validate TTL values because supported values vary across + providers, deployments, and self-hosted OpenAI-compatible services. When set, + the value is forwarded to the resolved provider's cache TTL field; providers + may accept, ignore, or reject it. ``None`` means "do not send a lifetime + hint" (provider default). + """ + + breakpoints: List[Literal["tools", "system", "messages"]] = Field(default_factory=lambda: ["system"]) + """Cache-control injection points for Anthropic-style providers. + + Used by native Anthropic and LiteLLM models routed to the Anthropic cache + family; ignored by OpenAI-managed providers. Current injection behavior: + + - ``"tools"``: stamp the last tool with ``cache_control``. For LiteLLM + Bedrock models this is represented as a ``tool_config`` cache point. + - ``"system"``: stamp the system prompt/system message. + - ``"messages"``: stamp one conversation-message breakpoint on the most + recent assistant message, keeping the current user turn outside the cached + prefix. LiteLLM uses its ``cache_control_injection_points`` support to + target that assistant message by index. + + An empty list means the SDK does not add Anthropic-style cache-control + injection points. User-authored provider-specific cache metadata is still + forwarded when supported by the underlying model adapter. + """ + + prompt_cache_key: Optional[str] = None + """OpenAI-managed family only. + + Improves cache-hit stability by keeping same-prefix requests sticky to the + same backend. Only used by OpenAI-managed providers. + """ diff --git a/trpc_agent_sdk/configs/_run_config.py b/trpc_agent_sdk/configs/_run_config.py new file mode 100644 index 000000000..50f16d581 --- /dev/null +++ b/trpc_agent_sdk/configs/_run_config.py @@ -0,0 +1,97 @@ +# 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. +"""Run configuration for TRPC Agent framework.""" + +from __future__ import annotations + +import sys +from typing import Any +from typing import Optional + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_validator + +from trpc_agent_sdk.log import logger + +from ._prompt_cache_config import PromptCacheConfig + + +class RunConfig(BaseModel): + """Configs for runtime behavior of agents.""" + + model_config = ConfigDict(extra="forbid", ) + """The pydantic model config.""" + + max_llm_calls: int = 500 + """ + A limit on the total number of llm calls for a given run. + + Valid Values: + - More than 0 and less than sys.maxsize: The bound on the number of llm + calls is enforced, if the value is set in this range. + - Less than or equal to 0: This allows for unbounded number of llm calls. + """ + + streaming: bool = True + """Whether to enable streaming mode. Default is True.""" + + agent_run_config: dict[str, Any] = Field(default_factory=dict) + """ + Additional config for the agent when invoke run_async. + """ + + custom_data: dict[str, Any] = Field(default_factory=dict) + """ + Custom data that can be passed to model factory callbacks for dynamic model creation. + + This data is accessible in model factory callbacks to enable runtime configuration, + such as dynamic API key retrieval or per-request model customization. + """ + + save_history_enabled: bool = False + """ Save history enabled.""" + + prompt_cache: Optional[PromptCacheConfig] = None + """Per-run prompt cache configuration override. + + When set, this takes precedence over the model-level ``prompt_cache_config`` + for the duration of the run. When ``None``, the model-level config (if any) + is used. + """ + + start_from_last_agent: bool = False + """ + Whether to start from the last active agent in the session instead of the root agent. + + When True: + - The runner will search session events to find the last responding agent + - If a matching agent is found in the current agent tree, execution resumes from that agent + - Falls back to root agent if no suitable agent is found + + When False (default): + - Always start from the root agent for normal scenarios + - This is the current default behavior + + Note: Human-in-the-loop scenarios always resume from the agent that triggered + the long-running operation, regardless of this setting. + """ + + @field_validator("max_llm_calls", mode="after") + @classmethod + def validate_max_llm_calls(cls, value: int) -> int: + if value == sys.maxsize: + raise ValueError(f"max_llm_calls should be less than {sys.maxsize}.") + elif value <= 0: + logger.warning( + "max_llm_calls is less than or equal to 0. This will result in" + " no enforcement on total number of llm calls that will be made for a" + " run. This may not be ideal, as this could result in a never" + " ending communication between the model and the agent in certain" + " cases.", ) + + return value diff --git a/trpc_agent_sdk/context/__init__.py b/trpc_agent_sdk/context/__init__.py new file mode 100644 index 000000000..4186166ec --- /dev/null +++ b/trpc_agent_sdk/context/__init__.py @@ -0,0 +1,51 @@ +# 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. +""" +TRPC Agent Context Module Initialization. + +This module serves as the entry point for the TRPC Agent context system, +providing the core context-related classes and utilities. Key exports include: + +1. Core Components: + - InvocationContext: Base context class for agent operations + - new_invocation_context_id: Context ID generator + +2. Purpose: + - Centralizes context-related imports + - Provides clean namespace for context operations + - Enables consistent context handling across the system + +3. Usage Patterns: + - Direct import of context classes + - ID generation for new contexts + - Tool execution context management +""" + +from ._agent_context import AgentContext +from ._agent_context import new_agent_context +from ._common import create_agent_context +from ._common import get_data_by_agent_ctx +from ._common import get_invocation_ctx +from ._common import pop_data_by_agent_ctx +from ._common import reset_invocation_ctx +from ._common import set_data_to_agent_ctx +from ._common import set_invocation_ctx +from ._invocation_context import InvocationContext +from ._invocation_context import new_invocation_context_id + +__all__ = [ + "AgentContext", + "new_agent_context", + "create_agent_context", + "get_data_by_agent_ctx", + "get_invocation_ctx", + "pop_data_by_agent_ctx", + "reset_invocation_ctx", + "set_data_to_agent_ctx", + "set_invocation_ctx", + "InvocationContext", + "new_invocation_context_id", +] diff --git a/trpc_agent_sdk/context/_agent_context.py b/trpc_agent_sdk/context/_agent_context.py new file mode 100644 index 000000000..96f6beac9 --- /dev/null +++ b/trpc_agent_sdk/context/_agent_context.py @@ -0,0 +1,71 @@ +# 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. +"""Agent context for TRPC Agent framework.""" + +from typing import Any +from typing import Dict + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import PrivateAttr + + +class AgentContext(BaseModel): + """ + AgentContext is user context for trpc_agent_sdk. + Used to control the interaction between user and framework. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + ) + + trpc_ctx: Any = None + """Trpc context.""" + + # Private attributes that cannot be set directly + _timeout: int = PrivateAttr(default=3000) + """Timeout in milliseconds.""" + + _metadata: Dict[str, Any] = PrivateAttr(default_factory=dict) + """Any metadata""" + + @property + def timeout(self) -> int: + """Get the timeout value.""" + return self._timeout + + def set_timeout(self, timeout: int) -> None: + """Set the timeout value.""" + self._timeout = timeout + + @property + def metadata(self) -> Dict[str, Any]: + """Get the metadata dictionary.""" + return self._metadata + + def with_metadata(self, key: str, value: Any) -> None: + """Add metadata with the given key and value.""" + self._metadata[key] = value + + def get_metadata(self, key: str, default: Any = None) -> Any: + """Get metadata value by key with optional default.""" + return self._metadata.get(key, default) + + +def new_agent_context(timeout: int = 3000, metadata: Dict[str, Any] | None = None) -> AgentContext: + """Create a new AgentContext instance.""" + context = AgentContext() + context.set_timeout(timeout) + + if metadata is None: + metadata = {} + + for key, value in metadata.items(): + context.with_metadata(key, value) + + return context diff --git a/trpc_agent_sdk/context/_common.py b/trpc_agent_sdk/context/_common.py new file mode 100644 index 000000000..f7ec9fd45 --- /dev/null +++ b/trpc_agent_sdk/context/_common.py @@ -0,0 +1,66 @@ +# 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. +"""TRPC Context Helper Functions. + +This module provides core utilities for managing TRPC context objects, +including context getter/setter functions and specialized context handlers. + +Functions: + get_data_by_agent_ctx: Retrieve generic context from TRPC context + set_data_to_agent_ctx: Store generic context in TRPC context + set_invocation_ctx: Store invocation context + get_invocation_ctx: Retrieve invocation context + create_agent_context: Create new TRPC context instance +""" +import contextvars +from typing import Any +from typing import Optional + +from ._agent_context import AgentContext +from ._constants import INVOCATION_CTX +from ._invocation_context import InvocationContext + + +def get_data_by_agent_ctx(agent_ctx: AgentContext, name: str, default: Any = None) -> Optional[Any]: + """Get context from trpc agent context""" + return agent_ctx.get_metadata(name, default) + + +def pop_data_by_agent_ctx(agent_ctx: AgentContext, name: str, default: Any = None) -> Optional[Any]: + """Get context from trpc agent context""" + return agent_ctx.metadata.pop(name, default) + + +def set_data_to_agent_ctx(agent_ctx: AgentContext, name: str, data: Any): + """Set context to trpc agent context""" + agent_ctx.with_metadata(name, data) + + +invocation_ctx: contextvars.ContextVar[InvocationContext] = contextvars.ContextVar(INVOCATION_CTX, + default=None) # type: ignore + + +def set_invocation_ctx(ctx: InvocationContext) -> contextvars.Token: + """Set invocation context to trpc invocation context""" + return invocation_ctx.set(ctx) + + +def get_invocation_ctx() -> InvocationContext: + """Get invocation context from trpc invocation context""" + return invocation_ctx.get() + + +def reset_invocation_ctx(token: contextvars.Token) -> None: + """Pop invocation context from trpc invocation context""" + try: + return invocation_ctx.reset(token) + except ValueError: + return None + + +def create_agent_context() -> AgentContext: + """Create trpc agent context""" + return AgentContext() diff --git a/trpc_agent_sdk/context/_constants.py b/trpc_agent_sdk/context/_constants.py new file mode 100644 index 000000000..0d183a3c6 --- /dev/null +++ b/trpc_agent_sdk/context/_constants.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 Apache-2.0. +"""Constants for context.""" + +INVOCATION_CTX: str = "invocation_ctx" diff --git a/trpc_agent_sdk/context/_invocation_context.py b/trpc_agent_sdk/context/_invocation_context.py new file mode 100644 index 000000000..4f00be0d8 --- /dev/null +++ b/trpc_agent_sdk/context/_invocation_context.py @@ -0,0 +1,348 @@ +# 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. +""" +Core context management for TRPC Agent invocations. + +This module defines the InvocationContext class which serves as the central container +for all contextual information during agent execution. Key responsibilities include: + +- Managing agent invocation lifecycle +- Providing access to core services (session, artifact, memory) +- Maintaining invocation state and configuration +- Handling event actions and streaming tools + +The InvocationContext is designed to be: +- Immutable for core attributes (enforced via Pydantic) +- Extensible for custom services +- Thread-safe for concurrent operations + +Example Usage: + ctx = InvocationContext( + session_service=session_service, + invocation_id=new_invocation_context_id(), + agent=agent, + session=session + ) + await agent.run_async(ctx) +""" + +from __future__ import annotations + +import asyncio +import uuid +from types import MappingProxyType +from typing import Any +from typing import List +from typing import Optional + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.abc import ArtifactEntry +from trpc_agent_sdk.abc import ArtifactId +from trpc_agent_sdk.abc import ArtifactServiceABC +from trpc_agent_sdk.abc import MemoryServiceABC +from trpc_agent_sdk.abc import SessionABC +from trpc_agent_sdk.abc import SessionServiceABC +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.types import ActiveStreamingTool +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import SearchMemoryResponse +from trpc_agent_sdk.types import State +from trpc_agent_sdk.utils import user_key + +from ._agent_context import AgentContext + + +class InvocationContext(BaseModel): + """An agent context represents the data of a single invocation of an agent. + + An invocation: + 1. Starts with a user message and ends with a final response. + 2. Can contain one or multiple agent calls. + 3. Is handled by runner.run_async(). + + An invocation runs an agent until it does not request to transfer to another + agent. + + An agent call: + 1. Is handled by agent.run(). + 2. Ends when agent.run() ends. + + The agent context provides access to all services and data needed during + agent execution, including session management, artifact storage, memory, + and configuration. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra="forbid", + ) + """The pydantic model config.""" + + # Required services + session_service: SessionServiceABC + """The session service for managing conversation sessions.""" + + # Optional services + artifact_service: Optional[ArtifactServiceABC] = None + """The artifact service for storing and retrieving files.""" + + memory_service: Optional[MemoryServiceABC] = None + """The memory service for storing and searching conversation history.""" + + # Core context data + invocation_id: str + """The id of this invocation context. Readonly.""" + + branch: Optional[str] = None + """The branch of the invocation context. + + The format is like agent_1.agent_2.agent_3, where agent_1 is the parent of + agent_2, and agent_2 is the parent of agent_3. + + Branch is used when multiple sub-agents shouldn't see their peer agents' + conversation history. + """ + + agent: AgentABC + """The current agent of this invocation context. Readonly.""" + + agent_context: AgentContext + """The agent context for user interaction control.""" + + user_content: Optional[Content] = None + """The user content that started this invocation. Readonly.""" + + session: SessionABC + """The current session of this invocation context. Readonly.""" + + # Control flags + end_invocation: bool = False + """Whether to end this invocation. + + Set to True in callbacks or tools to terminate this invocation.""" + + # Configuration + run_config: Optional[RunConfig] = None + """Configuration for agent execution under this invocation.""" + + event_actions: EventActions = Field(default=EventActions(), init=True) + + callback_state: Optional[State] = None + + active_streaming_tools: Optional[dict[str, ActiveStreamingTool]] = None + """The running streaming tools of this invocation.""" + + function_call_id: Optional[str] = None + """The ID of the current function call.""" + + override_messages: Optional[List[Content]] = None + """Optional pre-built messages to use instead of session history. + + When provided, LlmAgent will use these messages as conversation context + instead of building history from session.events. This enables TeamAgent + to control member agent context. + + This field is typically set by TeamAgent when executing member agents + with controlled message history. + """ + + session_key: Optional[Any] = None + """Session key for cancellation tracking. Set by Runner. + + This field stores the SessionKey used to track cancellation state + for the current run. It is set by Runner.run_async() and used by + agents to check for cancellation at checkpoints. + """ + + @property + def app_name(self) -> str: + """Get the application name from the session.""" + return self.session.app_name + + @property + def user_id(self) -> str: + """Get the user ID from the session.""" + return self.session.user_id + + @property + def session_id(self) -> str: + """Get the session ID from the session.""" + return self.session.id + + @property + def agent_name(self) -> str: + """Gets the name of the currently executing agent. + + Returns: + The name identifier of the agent that is currently processing + this invocation. + """ + return self.agent.name + + @property + def session_state(self) -> MappingProxyType[str, Any]: + """Gets an immutable view of the current session state. + + Returns: + A read-only mapping proxy of the current session state dictionary. + This prevents accidental modification of the session state while + allowing read access to state values. + + Note: + The returned MappingProxyType ensures any attempt to modify the + state through this property will raise an AttributeError. + """ + return MappingProxyType(self.session.state) + + @property + def actions(self) -> EventActions: + return self.event_actions + + @property + def state(self) -> State: + """Get the delta-aware state for this invocation context. + + Returns: + State: Mutable state object that tracks changes in delta + """ + if self.callback_state is None: + self.callback_state = State(value=self.session.state, delta=self.event_actions.state_delta) + return self.callback_state + + async def load_artifact(self, filename: str, version: Optional[int] = None) -> Optional[ArtifactEntry]: + """Loads an artifact attached to the current session. + + Args: + filename: The filename of the artifact. + version: The version of the artifact. If None, the latest version will be + returned. + + Returns: + The artifact. + """ + if self.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + return await self.artifact_service.load_artifact( + artifact_id=ArtifactId( + app_name=self.app_name, + user_id=self.user_id, + session_id=self.session.id, + filename=filename, + ), + version=version, + ) + + async def save_artifact(self, filename: str, artifact: Part) -> int: + """Saves an artifact and records it as delta for the current session. + + Args: + filename: The filename of the artifact. + artifact: The artifact to save. + + Returns: + The version of the artifact. + """ + if self.artifact_service is None: + raise ValueError("Artifact service is not initialized.") + version = await self.artifact_service.save_artifact( + artifact_id=ArtifactId( + app_name=self.app_name, + user_id=self.user_id, + session_id=self.session.id, + filename=filename, + ), + artifact=artifact, + ) + self.event_actions.artifact_delta[filename] = version + return version + + async def list_artifacts(self) -> list[str]: + """List all artifact filenames associated with the current session. + + Returns: + list[str]: Names of available artifacts + + Raises: + ValueError: If artifact service is not initialized + + Behavior: + - Queries artifact service using current session context + - Returns empty list if no artifacts exist + """ + if self.artifact_service is None: + raise ValueError('Artifact service is not initialized.') + return await self.artifact_service.list_artifact_keys(artifact_id=ArtifactId( + app_name=self.app_name, + user_id=self.user_id, + session_id=self.session.id, + ), ) + + async def search_memory(self, query: str) -> SearchMemoryResponse: + """Search user's memory using the provided query string. + + Args: + query: Search string to match against stored memories + + Returns: + SearchMemoryResponse: Structured search results + + Raises: + ValueError: If memory service is not available + + Notes: + - Results are scoped to current user and application + - Supports semantic search capabilities + """ + if self.memory_service is None: + raise ValueError('Memory service is not available.') + return await self.memory_service.search_memory( + key=user_key(self.app_name, self.user_id), + query=query, + agent_context=self.agent_context, + ) + + async def raise_if_cancelled(self) -> None: + """Raise RunCancelledException if this run is cancelled. + + This is a convenience method for checkpoint checks. + + Raises: + RunCancelledException: If cancelled. + """ + if self.session_key is None: + return + from trpc_agent_sdk.cancel import raise_if_cancelled + await raise_if_cancelled(self.session_key) + + async def get_cancel_event(self) -> Optional[asyncio.Event]: + """Get the cancellation event for this invocation. + + This returns the asyncio.Event that will be set when cancellation + is requested for the current run. Useful for concurrent cancel + detection in streaming scenarios. + + Returns: + The asyncio.Event for cancellation, or None if cancel is not requested. + """ + if self.session_key is None: + return None + from trpc_agent_sdk.cancel import get_cancel_event + return await get_cancel_event(self.session_key) + + +def new_invocation_context_id() -> str: + """Generate a new unique invocation context ID. + + Returns: + str: A new unique invocation context ID with 'e-' prefix + """ + return "e-" + str(uuid.uuid4()) diff --git a/trpc_agent_sdk/dsl/__init__.py b/trpc_agent_sdk/dsl/__init__.py new file mode 100644 index 000000000..42ea8be29 --- /dev/null +++ b/trpc_agent_sdk/dsl/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""DSL module for TRPC Agent framework.""" diff --git a/trpc_agent_sdk/dsl/graph/__init__.py b/trpc_agent_sdk/dsl/graph/__init__.py new file mode 100644 index 000000000..360665639 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/__init__.py @@ -0,0 +1,172 @@ +# 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. +"""TRPC-Agent Graph Module. + +User-facing API for building graph-based agent workflows. +Uses LangGraph as the underlying execution engine. + +This module provides a minimal, focused API for building graph-based workflows. +For event utilities, import from: + - trpc_agent_dsl.graph.events - Event building and inspection (EventBuilder, EventUtils) + +Example with NodeConfig pattern (recommended): + >>> from trpc_agent_sdk.dsl.graph import ( + ... State, + ... StateGraph, + ... GraphAgent, + ... NodeConfig, + ... STATE_KEY_USER_INPUT, + ... START, + ... END, + ... ) + >>> + >>> class MyState(State): + ... result: str + >>> + >>> config = NodeConfig( + ... name="Greeter", + ... description="Greets the user" + ... ) + >>> + >>> async def greet(state: MyState) -> dict: + ... return {"result": f"Hello, {state.get(STATE_KEY_USER_INPUT, 'world')}!"} + >>> + >>> graph = StateGraph(MyState) + >>> graph.add_node("greet", greet, config=config) + >>> graph.add_edge(START, "greet") + >>> graph.add_edge("greet", END) + >>> + >>> agent = GraphAgent( + ... name="greeter", + ... graph=graph.compile() + ... ) + +Node Signatures: + Nodes support signatures for different use cases: + + 1. Simple: async def node(state: State) -> dict + - For computations that don't need streaming + + 2. Streaming (sync): async def node(state: State, writer: EventWriter) -> dict + - For operations that emit partial results without awaits + + 3. Streaming (async): async def node(state: State, async_writer: AsyncEventWriter) -> dict + - For operations that want to await event writes + + 4. Context: async def node(state: State, ctx: InvocationContext) -> dict + - For operations needing session info or full context + + 5. Context + streaming also supported using either writer parameter. + You may also request both `writer` and `async_writer` in one node. + +Thread Safety: + Context is passed via config["configurable"]["invocation_context"] + for thread-safe concurrent execution. The graph instance is immutable + and can be safely reused across concurrent invocations. +""" + +# ============================================================================= +# Core API - Main graph building classes +# ============================================================================= +from ._callbacks import NodeCallbackContext +from ._callbacks import NodeCallbacks +from ._callbacks import create_logging_callbacks +from ._callbacks import merge_callbacks +from ._constants import END +from ._constants import START +from ._constants import STATE_KEY_AGENT_CALLBACKS +from ._constants import STATE_KEY_CURRENT_NODE_ID +from ._constants import STATE_KEY_EXEC_CONTEXT +from ._constants import STATE_KEY_LAST_RESPONSE +from ._constants import STATE_KEY_LAST_RESPONSE_ID +from ._constants import STATE_KEY_LAST_TOOL_RESPONSE +from ._constants import STATE_KEY_MESSAGES +from ._constants import STATE_KEY_METADATA +from ._constants import STATE_KEY_MODEL_CALLBACKS +from ._constants import STATE_KEY_NODE_CALLBACKS +from ._constants import STATE_KEY_NODE_RESPONSES +from ._constants import STATE_KEY_ONE_SHOT_MESSAGES +from ._constants import STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE +from ._constants import STATE_KEY_SESSION +from ._constants import STATE_KEY_STEP_NUMBER +from ._constants import STATE_KEY_TOOL_CALLBACKS +from ._constants import STATE_KEY_USER_INPUT +from ._constants import is_unsafe_state_key +from ._event_writer import AsyncEventWriter +from ._event_writer import EventWriter +from ._event_writer import EventWriterBase +from ._graph_agent import GraphAgent +from ._interrupt import interrupt +from ._memory_saver import MemorySaver +from ._memory_saver import MemorySaverOption +from ._memory_saver import has_graph_internal_checkpoint_state +from ._memory_saver import strip_graph_internal_checkpoint_state +from ._node_config import NodeConfig +from ._state import State +from ._state import StateUtils +from ._state import append_list +from ._state import merge_dict +from ._state import messages_reducer +from ._state_graph import CompiledStateGraph +from ._state_graph import StateGraph +from ._events import EventUtils +from ._events import ExecutionPhase +from ._events import ModelExecutionMetadata +from ._events import NodeExecutionMetadata +from ._events import ToolExecutionMetadata +from ._state_mapper import StateMapper +from ._state_mapper import SubgraphResult + +__all__ = [ + "NodeCallbacks", + "END", + "START", + "STATE_KEY_LAST_RESPONSE", + "STATE_KEY_LAST_RESPONSE_ID", + "STATE_KEY_LAST_TOOL_RESPONSE", + "STATE_KEY_MESSAGES", + "STATE_KEY_NODE_RESPONSES", + "STATE_KEY_USER_INPUT", + "STATE_KEY_AGENT_CALLBACKS", + "STATE_KEY_CURRENT_NODE_ID", + "STATE_KEY_EXEC_CONTEXT", + "STATE_KEY_METADATA", + "STATE_KEY_MODEL_CALLBACKS", + "STATE_KEY_NODE_CALLBACKS", + "STATE_KEY_ONE_SHOT_MESSAGES", + "STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE", + "STATE_KEY_SESSION", + "STATE_KEY_STEP_NUMBER", + "STATE_KEY_TOOL_CALLBACKS", + "is_unsafe_state_key", + "AsyncEventWriter", + "EventWriter", + "EventWriterBase", + "GraphAgent", + "interrupt", + "MemorySaver", + "MemorySaverOption", + "has_graph_internal_checkpoint_state", + "strip_graph_internal_checkpoint_state", + "NodeConfig", + "State", + "StateUtils", + "append_list", + "merge_dict", + "messages_reducer", + "CompiledStateGraph", + "StateGraph", + "StateMapper", + "SubgraphResult", + "NodeCallbackContext", + "create_logging_callbacks", + "merge_callbacks", + "EventUtils", + "ExecutionPhase", + "ModelExecutionMetadata", + "NodeExecutionMetadata", + "ToolExecutionMetadata", +] diff --git a/trpc_agent_sdk/dsl/graph/_callbacks.py b/trpc_agent_sdk/dsl/graph/_callbacks.py new file mode 100644 index 000000000..05b4300be --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_callbacks.py @@ -0,0 +1,239 @@ +# 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. +"""Callback system for graph node lifecycle events. + +This module provides callback types and the NodeCallbacks class for +registering callbacks that execute during node lifecycle events. + +Mirrors the callback system from trpc-agent-go/graph/callbacks.go. +""" + +from dataclasses import dataclass +from dataclasses import field +from datetime import datetime +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event + +# ============================================================================= +# Callback Context +# ============================================================================= + + +@dataclass +class NodeCallbackContext: + """Context passed to node callbacks. + + Contains information about the node being executed and the + current execution state. + + Attributes: + node_id: ID of the node being executed + node_name: Human-readable name of the node + node_type: Type of node (function, llm, tool, agent) + step_number: Current step number in graph execution + execution_start_time: When node execution started + invocation_id: Current invocation ID + session_id: Current session ID + invocation_context: Full invocation context for advanced use cases + """ + node_id: str + node_name: str = "" + node_type: str = "function" + step_number: int = 0 + execution_start_time: Optional[datetime] = None + invocation_id: str = "" + session_id: str = "" + invocation_context: Optional[InvocationContext] = None + + def __post_init__(self): + if self.execution_start_time is None: + self.execution_start_time = datetime.now() + if not self.node_name: + self.node_name = self.node_id + + +# ============================================================================= +# Callback Type Definitions +# ============================================================================= + +# Before node callback: Called before node execution +# Args: (context, state) -> Optional state update or None to continue +BeforeNodeCallback = Callable[[NodeCallbackContext, dict[str, Any]], Awaitable[Optional[dict[str, Any]]]] + +# After node callback: Called after successful node execution +# Args: (context, state, result, error) -> Optional modified result +AfterNodeCallback = Callable[[NodeCallbackContext, dict[str, Any], Any, Optional[Exception]], Awaitable[Optional[Any]]] + +# On error callback: Called when node execution fails +# Args: (context, state, error) -> None +OnNodeErrorCallback = Callable[[NodeCallbackContext, dict[str, Any], Exception], Awaitable[None]] + +# Agent event callback: Called when sub-agent emits an event +# Args: (context, state, event) -> None +# The event parameter is a trpc_agent_sdk.events.Event instance +AgentEventCallback = Callable[[NodeCallbackContext, dict[str, Any], "Event"], Awaitable[None]] + +# ============================================================================= +# NodeCallbacks Class +# ============================================================================= + + +@dataclass +class NodeCallbacks: + """Collection of callbacks for node lifecycle events. + + This class holds lists of callbacks that are executed at different + points during node execution. Callbacks are executed in order. + + Example: + >>> callbacks = NodeCallbacks() + >>> callbacks.register_before_node(my_before_callback) + >>> callbacks.register_after_node(my_after_callback) + >>> callbacks.register_on_error(my_error_callback) + >>> + >>> graph.add_node("my_node", my_action, callbacks=callbacks) + """ + before_node: list[BeforeNodeCallback] = field(default_factory=list) + after_node: list[AfterNodeCallback] = field(default_factory=list) + on_error: list[OnNodeErrorCallback] = field(default_factory=list) + agent_event: list[AgentEventCallback] = field(default_factory=list) + + def register_before_node(self, callback: BeforeNodeCallback) -> None: + """Register a callback to run before node execution. + + Args: + callback: Async function to call before node runs + """ + self.before_node.append(callback) + + def register_after_node(self, callback: AfterNodeCallback) -> None: + """Register a callback to run after node execution. + + Args: + callback: Async function to call after node completes + """ + self.after_node.append(callback) + + def register_on_error(self, callback: OnNodeErrorCallback) -> None: + """Register a callback to run when node execution fails. + + Args: + callback: Async function to call on error + """ + self.on_error.append(callback) + + def register_agent_event(self, callback: AgentEventCallback) -> None: + """Register a callback for agent node events. + + Args: + callback: Async function to call on agent events + """ + self.agent_event.append(callback) + + +def merge_callbacks( + global_callbacks: Optional[NodeCallbacks], + node_callbacks: Optional[NodeCallbacks], +) -> Optional[NodeCallbacks]: + """Merge global and per-node callbacks. + + Global callbacks run first for before/error callbacks. + Per-node callbacks run first for after callbacks (to allow modification). + + Args: + global_callbacks: Graph-level callbacks + node_callbacks: Node-specific callbacks + + Returns: + Merged callbacks or None if both are None + """ + if global_callbacks is None and node_callbacks is None: + return None + + if global_callbacks is None: + return node_callbacks + + if node_callbacks is None: + return global_callbacks + + merged = NodeCallbacks() + + # Before: global first, then per-node + merged.before_node = global_callbacks.before_node + node_callbacks.before_node + + # After: per-node first, then global (so per-node can modify) + merged.after_node = node_callbacks.after_node + global_callbacks.after_node + + # Error: global first, then per-node + merged.on_error = global_callbacks.on_error + node_callbacks.on_error + + # Agent events: global first, then per-node + merged.agent_event = global_callbacks.agent_event + node_callbacks.agent_event + + return merged + + +# ============================================================================= +# Convenience Functions +# ============================================================================= + + +def create_logging_callbacks( + logger: Any = None, + log_before: bool = True, + log_after: bool = True, + log_errors: bool = True, +) -> NodeCallbacks: + """Create callbacks that log node lifecycle events. + + Args: + logger: Logger instance (uses print if None) + log_before: Whether to log before node execution + log_after: Whether to log after node execution + log_errors: Whether to log errors + + Returns: + NodeCallbacks with logging functions + """ + + def log(msg: str): + if logger: + logger.info(msg) + else: + print(msg) + + callbacks = NodeCallbacks() + + if log_before: + + async def before_log(ctx: NodeCallbackContext, state: dict) -> None: + log(f"[{ctx.node_id}] Starting node execution") + return None + + callbacks.register_before_node(before_log) + + if log_after: + + async def after_log(ctx: NodeCallbackContext, state: dict, result: Any, error: Optional[Exception]) -> None: + duration = (datetime.now() - ctx.execution_start_time).total_seconds() if ctx.execution_start_time else 0 + log(f"[{ctx.node_id}] Completed in {duration:.3f}s") + return None + + callbacks.register_after_node(after_log) + + if log_errors: + + async def error_log(ctx: NodeCallbackContext, state: dict, error: Exception) -> None: + log(f"[{ctx.node_id}] Error: {error}") + + callbacks.register_on_error(error_log) + + return callbacks diff --git a/trpc_agent_sdk/dsl/graph/_constants.py b/trpc_agent_sdk/dsl/graph/_constants.py new file mode 100644 index 000000000..c2d04072b --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_constants.py @@ -0,0 +1,194 @@ +# 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. +"""State key constants and definitions for TRPC-Agent graph module. + +This module centralizes all string literal keys used in graph state management. + +Usage: + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_USER_INPUT, STATE_KEY_MESSAGES + >>> state[STATE_KEY_USER_INPUT] = "Hello" + >>> messages = state.get(STATE_KEY_MESSAGES, []) +""" + +# ============================================================================= +# Core State Keys +# ============================================================================= + +# User input - the current turn's user message +STATE_KEY_USER_INPUT = "user_input" + +# Messages - conversation history +STATE_KEY_MESSAGES = "messages" + +# Last response - most recent node response (text content) +STATE_KEY_LAST_RESPONSE = "last_response" + +# Last response ID - ID for tracking/referencing the last response +STATE_KEY_LAST_RESPONSE_ID = "last_response_id" + +# Last tool response - result of the last tool execution +STATE_KEY_LAST_TOOL_RESPONSE = "last_tool_response" + +# Node responses - responses keyed by node name +STATE_KEY_NODE_RESPONSES = "node_responses" + +# ============================================================================= +# One-Shot Message Keys (for three-stage LLM execution) +# ============================================================================= + +# One-shot messages - consumed after single use (highest priority) +STATE_KEY_ONE_SHOT_MESSAGES = "one_shot_messages" + +# One-shot messages by node - node-specific one-shot messages +STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE = "one_shot_messages_by_node" + +# ============================================================================= +# Metadata and Context Keys +# ============================================================================= + +# Metadata - TRPC-Agent metadata (invocation_id, branch, session_id, agent_name) +STATE_KEY_METADATA = "metadata" + +# Session - reference to the current Session object +STATE_KEY_SESSION = "session" + +# Current node ID - ID of the currently executing node +STATE_KEY_CURRENT_NODE_ID = "current_node_id" + +# Execution context - execution context for advanced use cases +STATE_KEY_EXEC_CONTEXT = "exec_context" + +# ============================================================================= +# Callback Keys +# ============================================================================= + +# Tool callbacks - callbacks for tool execution +STATE_KEY_TOOL_CALLBACKS = "tool_callbacks" + +# Model callbacks - callbacks for model execution +STATE_KEY_MODEL_CALLBACKS = "model_callbacks" + +# Agent callbacks - callbacks for agent execution +STATE_KEY_AGENT_CALLBACKS = "agent_callbacks" + +# Node callbacks - callbacks for node execution +STATE_KEY_NODE_CALLBACKS = "node_callbacks" + +# ============================================================================= +# Metadata Sub-Keys +# ============================================================================= + +# Invocation ID within metadata +METADATA_KEY_INVOCATION_ID = "invocation_id" + +# Session ID within metadata +METADATA_KEY_SESSION_ID = "session_id" + +# Branch within metadata +METADATA_KEY_BRANCH = "branch" + +# Agent name within metadata +METADATA_KEY_AGENT_NAME = "agent_name" + +# ============================================================================= +# Node Type Values +# ============================================================================= + +NODE_TYPE_FUNCTION = "function" +NODE_TYPE_LLM = "llm" +NODE_TYPE_TOOL = "tool" +NODE_TYPE_AGENT = "agent" +NODE_TYPE_CODE = "code" +NODE_TYPE_KNOWLEDGE = "knowledge" + +# ============================================================================= +# Graph Boundary Constants +# ============================================================================= + +# Start and end node identifiers for graph routing +START = "__start__" +END = "__end__" + +# ============================================================================= +# Step Tracking Keys +# ============================================================================= + +# Step number - current step in graph execution +STATE_KEY_STEP_NUMBER = "step_number" + +# ============================================================================= +# Stream/Event Acknowledgement Keys +# ============================================================================= + +# Custom stream payload key used to carry graph events from nodes +STREAM_KEY_EVENT = "_trpc_graph_event" + +# Custom stream payload key used to acknowledge GraphAgent has received an event +STREAM_KEY_ACK = "_trpc_graph_ack" + +# ============================================================================= +# Internal Checkpoint Storage Keys +# ============================================================================= + +# LangGraph checkpoint data stored in Session.state +STATE_KEY_CHECKPOINTS = "_trpc_graph_checkpoints" +STATE_KEY_CHECKPOINT_WRITES = "_trpc_graph_checkpoint_writes" +STATE_KEY_CHECKPOINT_BLOBS = "_trpc_graph_checkpoint_blobs" + +# ============================================================================= +# Internal Interrupt Storage Keys +# ============================================================================= + +# Pending interrupt marker and metadata stored in Session.state +STATE_KEY_PENDING_INTERRUPT = "_trpc_graph_pending_interrupt" +STATE_KEY_PENDING_INTERRUPT_ID = "_trpc_graph_pending_interrupt_id" +STATE_KEY_PENDING_INTERRUPT_AUTHOR = "_trpc_graph_pending_interrupt_author" +STATE_KEY_PENDING_INTERRUPT_BRANCH = "_trpc_graph_pending_interrupt_branch" + +# Prefix for synthetic function call IDs used by graph interrupt bridge +STATE_KEY_LONG_RUNNING_PREFIX = "__trpc_graph_long_running__" + +# ============================================================================= +# Role Values +# ============================================================================= + +ROLE_USER = "user" +ROLE_MODEL = "model" +ROLE_FUNCTION = "function" +ROLE_SYSTEM = "system" + +# ============================================================================= +# Unsafe State Keys (not serializable or should not be exposed) +# ============================================================================= + +UNSAFE_STATE_KEYS = frozenset({ + STATE_KEY_SESSION, + STATE_KEY_EXEC_CONTEXT, + STATE_KEY_CURRENT_NODE_ID, + STATE_KEY_TOOL_CALLBACKS, + STATE_KEY_MODEL_CALLBACKS, + STATE_KEY_AGENT_CALLBACKS, + STATE_KEY_NODE_CALLBACKS, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINT_BLOBS, + STATE_KEY_PENDING_INTERRUPT, + STATE_KEY_PENDING_INTERRUPT_ID, + STATE_KEY_PENDING_INTERRUPT_AUTHOR, + STATE_KEY_PENDING_INTERRUPT_BRANCH, +}) + + +def is_unsafe_state_key(key: str) -> bool: + """Check if a state key is unsafe for serialization or exposure. + + Args: + key: State key to check + + Returns: + True if the key is unsafe + """ + return key in UNSAFE_STATE_KEYS diff --git a/trpc_agent_sdk/dsl/graph/_event_writer.py b/trpc_agent_sdk/dsl/graph/_event_writer.py new file mode 100644 index 000000000..4f235510e --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_event_writer.py @@ -0,0 +1,389 @@ +# 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. +"""Event writers for graph nodes. + +This module provides EventWriter (sync) and AsyncEventWriter (async) +for emitting streaming events during execution, wrapping the engine stream writer. + +All events use the unified Event class from trpc_agent_sdk.events via EventBuilder. +""" + +import asyncio +from datetime import datetime +from typing import Any +from typing import Callable +from typing import Optional + +from google.genai.types import Content +from google.genai.types import Part + +from trpc_agent_sdk.events import Event + +from ._constants import STREAM_KEY_ACK +from ._constants import STREAM_KEY_EVENT +from ._events import EventBuilder + + +class EventWriterBase: + """Base class for graph event writers. + + Stores shared context and helpers for building events. + """ + + def __init__( + self, + writer: Callable[[dict[str, Any]], None], + invocation_id: str, + author: str, + branch: str, + request_id: Optional[str] = None, + parent_invocation_id: Optional[str] = None, + filter_key: Optional[str] = None, + ): + """Initialize the event writer base. + + Args: + writer: Engine stream writer function + invocation_id: Current invocation ID + author: Name of the node/agent writing events + branch: Current branch in the agent tree + request_id: Optional request ID for tracing (from context) + parent_invocation_id: Optional parent invocation ID for sub-agent executions + filter_key: Optional hierarchical filter key + """ + self._writer = writer + self._builder = EventBuilder(invocation_id, author, branch) + + # Store optional context fields + self._request_id = request_id + self._parent_invocation_id = parent_invocation_id + self._filter_key = filter_key + + # Track start times for duration calculation + self._node_start_time: Optional[datetime] = None + self._model_start_time: Optional[datetime] = None + self._tool_start_times: dict[str, datetime] = {} + + def _apply_optional_context(self, event: Event) -> None: + if self._request_id: + event.request_id = self._request_id + if self._parent_invocation_id: + event.parent_invocation_id = self._parent_invocation_id + if self._filter_key: + event.filter_key = self._filter_key + + def _emit_event(self, event: Event) -> None: + self._writer({STREAM_KEY_EVENT: event}) + + def _build_text_event(self, text: str, partial: bool = True) -> Event: + content = Content(role="model", parts=[Part.from_text(text=text)]) + event = Event( + invocation_id=self._builder.invocation_id, + author=self._builder.author, + branch=self._builder.branch, + content=content, + partial=partial, + ) + self._apply_optional_context(event) + return event + + def _build_content_event(self, content: Content, partial: bool = False) -> Event: + event = Event( + invocation_id=self._builder.invocation_id, + author=self._builder.author, + branch=self._builder.branch, + content=content, + partial=partial, + ) + self._apply_optional_context(event) + return event + + def _build_node_start_event( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + input_keys: Optional[list[str]] = None, + node_description: Optional[str] = None, + ) -> Event: + self._node_start_time = datetime.now() + return self._builder.node_start( + node_id=node_id, + node_type=node_type, + node_description=node_description, + step_number=step_number, + input_keys=input_keys, + ) + + def _build_node_complete_event( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + output_keys: Optional[list[str]] = None, + node_description: Optional[str] = None, + ) -> Event: + return self._builder.node_complete( + node_id=node_id, + node_type=node_type, + node_description=node_description, + step_number=step_number, + start_time=self._node_start_time, + output_keys=output_keys, + ) + + def _build_node_error_event( + self, + node_id: str, + error: str, + node_type: str = "function", + step_number: int = 0, + node_description: Optional[str] = None, + ) -> Event: + return self._builder.node_error( + node_id=node_id, + error=error, + node_type=node_type, + node_description=node_description, + step_number=step_number, + start_time=self._node_start_time, + ) + + def _build_model_start_event(self, model_name: str, input_text: str = "") -> Event: + self._model_start_time = datetime.now() + return self._builder.model_start(model_name, self._builder.author, input_text) + + def _build_model_complete_event( + self, + model_name: str, + start_time: Optional[datetime] = None, + input_text: str = "", + output_text: str = "", + error: Optional[str] = None, + ) -> Event: + actual_start_time = start_time or self._model_start_time + return self._builder.model_complete(model_name, self._builder.author, actual_start_time, input_text, + output_text, error) + + def _build_tool_start_event(self, tool_name: str, tool_id: str, input_args: str = "") -> Event: + self._tool_start_times[tool_id] = datetime.now() + return self._builder.tool_start(tool_name, tool_id, self._builder.author, input_args) + + def _build_tool_complete_event( + self, + tool_name: str, + tool_id: str, + start_time: Optional[datetime] = None, + input_args: str = "", + output_result: str = "", + error: Optional[str] = None, + ) -> Event: + actual_start_time = start_time or self._tool_start_times.get(tool_id) + return self._builder.tool_complete(tool_name, tool_id, self._builder.author, actual_start_time, input_args, + output_result, error) + + def _clear_tool_start_time(self, tool_id: str) -> None: + self._tool_start_times.pop(tool_id, None) + + # ========================================================================= + # Properties + # ========================================================================= + + @property + def invocation_id(self) -> str: + """Get the current invocation ID.""" + return self._builder.invocation_id + + @property + def author(self) -> str: + """Get the current author (node name).""" + return self._builder.author + + @property + def branch(self) -> str: + """Get the current branch.""" + return self._builder.branch + + @property + def builder(self) -> EventBuilder: + """Get the EventBuilder for advanced event creation.""" + return self._builder + + +class EventWriter(EventWriterBase): + """Event writer for graph nodes. + + Wraps the engine's StreamWriter to write TRPC-Agent Events. + Nodes receive an EventWriter to stream partial results during execution. + + Internally uses EventBuilder for consistent event creation. + + Example: + >>> async def my_node(state: State, writer: EventWriter) -> dict: + ... writer.write_text("Processing...") + ... # Do some work + ... writer.write_text("Done!", partial=False) + ... return {"result": "completed"} + """ + + # ========================================================================= + # Basic Event Methods + # ========================================================================= + + def write_text(self, text: str, partial: bool = True) -> None: + """Write a text response event.""" + self._emit_event(self._build_text_event(text, partial)) + + def write_content(self, content: Content, partial: bool = False) -> None: + """Write a Content object as event.""" + self._emit_event(self._build_content_event(content, partial)) + + def write_event(self, event: Event) -> None: + """Write any TRPC-Agent Event directly.""" + self._emit_event(event) + + +class AsyncEventWriter(EventWriterBase): + """Async wrapper for EventWriterBase. + + Provides an awaitable interface to emit events and yield control so + queued stream events can flush promptly. + """ + + async def _emit_and_flush(self, event: Event) -> None: + """Emit an event and wait until GraphAgent consumes it.""" + ack = asyncio.get_running_loop().create_future() + self._writer({STREAM_KEY_EVENT: event, STREAM_KEY_ACK: ack}) + await ack + + # ========================================================================= + # Basic Event Methods + # ========================================================================= + + async def write_text(self, text: str, partial: bool = True) -> None: + """Write a text response event.""" + await self._emit_and_flush(self._build_text_event(text, partial)) + + async def write_content(self, content: Content, partial: bool = False) -> None: + """Write a Content object as event.""" + await self._emit_and_flush(self._build_content_event(content, partial)) + + async def write_event(self, event: Event) -> None: + """Write any TRPC-Agent Event directly.""" + await self._emit_and_flush(event) + + # ========================================================================= + # Node Execution Events + # ========================================================================= + + async def write_node_start( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + input_keys: Optional[list[str]] = None, + node_description: Optional[str] = None, + ) -> None: + """Write a node execution start event.""" + await self._emit_and_flush( + self._build_node_start_event( + node_id=node_id, + node_type=node_type, + node_description=node_description, + step_number=step_number, + input_keys=input_keys, + )) + + async def write_node_complete( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + output_keys: Optional[list[str]] = None, + node_description: Optional[str] = None, + ) -> None: + """Write a node execution complete event.""" + await self._emit_and_flush( + self._build_node_complete_event( + node_id=node_id, + node_type=node_type, + node_description=node_description, + step_number=step_number, + output_keys=output_keys, + )) + + async def write_node_error( + self, + node_id: str, + error: str, + node_type: str = "function", + step_number: int = 0, + node_description: Optional[str] = None, + ) -> None: + """Write a node execution error event.""" + await self._emit_and_flush( + self._build_node_error_event( + node_id=node_id, + error=error, + node_type=node_type, + node_description=node_description, + step_number=step_number, + )) + + # ========================================================================= + # Model Execution Events + # ========================================================================= + + async def write_model_start(self, model_name: str, input_text: str = "") -> None: + """Write a model execution start event.""" + await self._emit_and_flush(self._build_model_start_event(model_name, input_text)) + + async def write_model_complete( + self, + model_name: str, + start_time: Optional[datetime] = None, + input_text: str = "", + output_text: str = "", + error: Optional[str] = None, + ) -> None: + """Write a model execution complete event.""" + await self._emit_and_flush( + self._build_model_complete_event( + model_name, + start_time=start_time, + input_text=input_text, + output_text=output_text, + error=error, + )) + + # ========================================================================= + # Tool Execution Events + # ========================================================================= + + async def write_tool_start(self, tool_name: str, tool_id: str, input_args: str = "") -> None: + """Write a tool execution start event.""" + await self._emit_and_flush(self._build_tool_start_event(tool_name, tool_id, input_args)) + + async def write_tool_complete( + self, + tool_name: str, + tool_id: str, + start_time: Optional[datetime] = None, + input_args: str = "", + output_result: str = "", + error: Optional[str] = None, + ) -> None: + """Write a tool execution complete event.""" + event = self._build_tool_complete_event( + tool_name, + tool_id, + start_time=start_time, + input_args=input_args, + output_result=output_result, + error=error, + ) + self._clear_tool_start_time(tool_id) + await self._emit_and_flush(event) diff --git a/trpc_agent_sdk/dsl/graph/_events/__init__.py b/trpc_agent_sdk/dsl/graph/_events/__init__.py new file mode 100644 index 000000000..1f6ba8f4b --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_events/__init__.py @@ -0,0 +1,44 @@ +# 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. +"""Events submodule for graph execution events. + +This module consolidates all event-related functionality: +- Constants and enums +- Metadata dataclasses +- EventBuilder for creating events +- EventUtils for extracting event information +""" + +# Constants and enums +from ._builder import EventBuilder +from ._constants import ExecutionPhase +from ._constants import GraphEventType +from ._constants import METADATA_KEY_MODEL +from ._constants import METADATA_KEY_NODE +from ._constants import METADATA_KEY_STATE +from ._constants import METADATA_KEY_TOOL +from ._helpers import EventUtils +from ._metadata import CompletionMetadata +from ._metadata import ModelExecutionMetadata +from ._metadata import NodeExecutionMetadata +from ._metadata import StateUpdateMetadata +from ._metadata import ToolExecutionMetadata + +__all__ = [ + "EventBuilder", + "ExecutionPhase", + "GraphEventType", + "METADATA_KEY_MODEL", + "METADATA_KEY_NODE", + "METADATA_KEY_STATE", + "METADATA_KEY_TOOL", + "EventUtils", + "CompletionMetadata", + "ModelExecutionMetadata", + "NodeExecutionMetadata", + "StateUpdateMetadata", + "ToolExecutionMetadata", +] diff --git a/trpc_agent_sdk/dsl/graph/_events/_builder.py b/trpc_agent_sdk/dsl/graph/_events/_builder.py new file mode 100644 index 000000000..9ecd19555 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_events/_builder.py @@ -0,0 +1,518 @@ +# 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. +"""EventBuilder for graph execution events. + +This module provides the EventBuilder class that creates Event objects for +various graph execution lifecycle events. All events use the unified Event +class from trpc_agent_sdk.events. + +The events are distinguished by: +1. The content text (describing what happened) +2. The actions.state_delta (containing structured event data) +3. The partial flag (indicating streaming vs final events) + +Event types: +- Node execution events (start, complete, error) +- Model execution events (LLM calls) +- Tool execution events +- Graph completion events + +Example: + >>> builder = EventBuilder(invocation_id="inv-123", author="my_agent", branch="main") + >>> start_event = builder.node_start("my_node") + >>> complete_event = builder.node_complete("my_node", start_time=start_time) +""" + +from datetime import datetime +from typing import Any +from typing import Optional + +from google.genai.types import Content +from google.genai.types import Part + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.types import EventActions + +from ._constants import ExecutionPhase +from ._constants import GRAPH_EXECUTION_KEY_END_TIME +from ._constants import GRAPH_EXECUTION_KEY_ERROR +from ._constants import GRAPH_EXECUTION_KEY_FINAL_STATE_KEYS +from ._constants import GRAPH_EXECUTION_KEY_PHASE +from ._constants import GRAPH_EXECUTION_KEY_START_TIME +from ._constants import GRAPH_EXECUTION_KEY_STATE_DELTA_KEYS +from ._constants import GRAPH_EXECUTION_KEY_TOTAL_DURATION_MS +from ._constants import GRAPH_EXECUTION_KEY_TOTAL_STEPS +from ._constants import GraphEventType +from ._constants import METADATA_KEY_MODEL +from ._constants import METADATA_KEY_NODE +from ._constants import METADATA_KEY_STATE +from ._constants import METADATA_KEY_TOOL +from ._metadata import ModelExecutionMetadata +from ._metadata import NodeExecutionMetadata +from ._metadata import StateUpdateMetadata +from ._metadata import ToolExecutionMetadata +from ._metadata import _store_metadata + + +class EventBuilder: + """Builder for creating graph execution events. + + Centralizes event creation logic with common fields (invocation_id, author, + branch) set once and reused across multiple event creations. + + Example: + >>> builder = EventBuilder(invocation_id="inv-123", author="my_node", branch="main") + >>> start_event = builder.node_start("my_node") + >>> complete_event = builder.node_complete("my_node", start_time=start_time) + """ + + def __init__( + self, + invocation_id: str, + author: str = "", + branch: str = "", + ): + """Initialize the EventBuilder. + + Args: + invocation_id: Current invocation ID (required for all events) + author: Default event author (can be overridden per event) + branch: Current branch in the agent tree + """ + self._invocation_id = invocation_id + self._author = author + self._branch = branch + + @property + def invocation_id(self) -> str: + """Get the invocation ID.""" + return self._invocation_id + + @property + def author(self) -> str: + """Get the default author.""" + return self._author + + @property + def branch(self) -> str: + """Get the branch.""" + return self._branch + + # ========================================================================= + # Internal Helpers + # ========================================================================= + + def _build_event( + self, + text: str, + state_delta: dict[str, Any], + partial: bool = False, + visible: bool = True, + author_override: Optional[str] = None, + object_type: Optional[str] = None, + ) -> Event: + """Build an Event with common fields. + + Args: + text: Human-readable description + state_delta: State delta dictionary (may contain structured metadata) + partial: Whether this is a partial/streaming event + visible: Whether this event is visible to observers + author_override: Override for the event author + object_type: Object type constant for event classification + + Returns: + Configured Event instance + """ + content = Content(role="model", parts=[Part.from_text(text=text)]) + event = Event( + invocation_id=self._invocation_id, + author=author_override or self._author, + branch=self._branch, + content=content, + partial=partial, + visible=visible, + actions=EventActions(state_delta=state_delta), + ) + # Set object type if provided + if object_type: + event.object = object_type + return event + + def _calc_duration_ms(self, start_time: Optional[datetime], end_time: datetime) -> float: + """Calculate duration in milliseconds.""" + if start_time: + return (end_time - start_time).total_seconds() * 1000 + return 0.0 + + def _truncate(self, text: str, max_len: int = 500) -> str: + """Truncate text to max length.""" + return text[:max_len] if text else "" + + # ========================================================================= + # Node Events + # ========================================================================= + + def node_start( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + input_keys: Optional[list[str]] = None, + model_name: Optional[str] = None, + model_input: Optional[str] = None, + node_description: Optional[str] = None, + ) -> Event: + """Create a node execution start event with structured metadata.""" + start_time = datetime.now() + + # Create structured metadata + metadata = NodeExecutionMetadata( + node_id=node_id, + node_type=node_type, + node_description=node_description, + phase=ExecutionPhase.START.value, + start_time=start_time.isoformat(), + step_number=step_number, + input_keys=input_keys or [], + model_name=model_name, + model_input=self._truncate(model_input, 500) if model_input else None, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_NODE, metadata) + + return self._build_event( + text=f"Starting node: {node_id}", + state_delta=state_delta, + partial=True, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_START, + ) + + def node_complete( + self, + node_id: str, + node_type: str = "function", + step_number: int = 0, + start_time: Optional[datetime] = None, + output_keys: Optional[list[str]] = None, + tool_calls: Optional[list[dict[str, Any]]] = None, + model_name: Optional[str] = None, + node_description: Optional[str] = None, + ) -> Event: + """Create a node execution complete event with structured metadata.""" + end_time = datetime.now() + duration_ms = self._calc_duration_ms(start_time, end_time) + + # Create structured metadata + metadata = NodeExecutionMetadata( + node_id=node_id, + node_type=node_type, + node_description=node_description, + phase=ExecutionPhase.COMPLETE.value, + start_time=start_time.isoformat() if start_time else None, + end_time=end_time.isoformat(), + duration_ms=duration_ms, + step_number=step_number, + output_keys=output_keys or [], + tool_calls=tool_calls or [], + model_name=model_name, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_NODE, metadata) + + return self._build_event( + text=f"Completed node: {node_id} ({duration_ms:.1f}ms)", + state_delta=state_delta, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_COMPLETE, + ) + + def node_error( + self, + node_id: str, + error: str, + node_type: str = "function", + step_number: int = 0, + start_time: Optional[datetime] = None, + node_description: Optional[str] = None, + ) -> Event: + """Create a node execution error event with structured metadata.""" + end_time = datetime.now() + duration_ms = self._calc_duration_ms(start_time, end_time) + + # Create structured metadata + metadata = NodeExecutionMetadata( + node_id=node_id, + node_type=node_type, + node_description=node_description, + phase=ExecutionPhase.ERROR.value, + start_time=start_time.isoformat() if start_time else None, + end_time=end_time.isoformat(), + duration_ms=duration_ms, + step_number=step_number, + error=error, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_NODE, metadata) + + # Build error message + text = f"Error in node {node_id}: {error}" + + return self._build_event( + text=text, + state_delta=state_delta, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_ERROR, + ) + + # ========================================================================= + # Model Events + # ========================================================================= + + def model_start( + self, + model_name: str, + node_id: str, + input_text: str = "", + step_number: int = 0, + ) -> Event: + """Create a model execution start event with structured metadata.""" + start_time = datetime.now() + + # Create structured metadata + metadata = ModelExecutionMetadata( + model_name=model_name, + node_id=node_id, + phase=ExecutionPhase.START.value, + start_time=start_time.isoformat(), + input_text=self._truncate(input_text, 500), + step_number=step_number, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_MODEL, metadata) + + return self._build_event( + text=f"Calling model: {model_name}", + state_delta=state_delta, + partial=True, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_EXECUTION, + ) + + def model_complete( + self, + model_name: str, + node_id: str, + start_time: Optional[datetime] = None, + input_text: str = "", + output_text: str = "", + error: Optional[str] = None, + step_number: int = 0, + ) -> Event: + """Create a model execution complete event with structured metadata.""" + end_time = datetime.now() + duration_ms = self._calc_duration_ms(start_time, end_time) + phase = ExecutionPhase.ERROR.value if error else ExecutionPhase.COMPLETE.value + + # Create structured metadata + metadata = ModelExecutionMetadata( + model_name=model_name, + node_id=node_id, + phase=phase, + start_time=start_time.isoformat() if start_time else None, + end_time=end_time.isoformat(), + duration_ms=duration_ms, + input_text=self._truncate(input_text, 500), + output_text=self._truncate(output_text, 500), + error=error, + step_number=step_number, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_MODEL, metadata) + + if error: + text = f"Model {model_name} failed: {error}" + else: + text = f"Model {model_name} completed ({duration_ms:.1f}ms)" + + return self._build_event( + text=text, + state_delta=state_delta, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_EXECUTION, + ) + + # ========================================================================= + # Tool Events + # ========================================================================= + + def tool_start( + self, + tool_name: str, + tool_id: str, + node_id: str, + input_args: str = "", + ) -> Event: + """Create a tool execution start event with structured metadata.""" + start_time = datetime.now() + + # Create structured metadata + metadata = ToolExecutionMetadata( + tool_name=tool_name, + tool_id=tool_id, + node_id=node_id, + phase=ExecutionPhase.START.value, + start_time=start_time.isoformat(), + input_args=self._truncate(input_args, 1000), + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_TOOL, metadata) + + return self._build_event( + text=f"Calling tool: {tool_name}", + state_delta=state_delta, + partial=True, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_EXECUTION, + ) + + def tool_complete( + self, + tool_name: str, + tool_id: str, + node_id: str, + start_time: Optional[datetime] = None, + input_args: str = "", + output_result: str = "", + error: Optional[str] = None, + ) -> Event: + """Create a tool execution complete event with structured metadata.""" + end_time = datetime.now() + duration_ms = self._calc_duration_ms(start_time, end_time) + phase = ExecutionPhase.ERROR.value if error else ExecutionPhase.COMPLETE.value + + # Create structured metadata + metadata = ToolExecutionMetadata( + tool_name=tool_name, + tool_id=tool_id, + node_id=node_id, + phase=phase, + start_time=start_time.isoformat() if start_time else None, + end_time=end_time.isoformat(), + duration_ms=duration_ms, + input_args=self._truncate(input_args, 1000), + output_result=self._truncate(output_result, 1000), + error=error, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_TOOL, metadata) + + if error: + text = f"Tool {tool_name} failed: {error}" + else: + text = f"Tool {tool_name} completed ({duration_ms:.1f}ms)" + + return self._build_event( + text=text, + state_delta=state_delta, + author_override=self._author or node_id, + object_type=GraphEventType.GRAPH_NODE_EXECUTION, + ) + + # ========================================================================= + # Graph Events + # ========================================================================= + + def graph_complete( + self, + total_steps: int, + start_time: datetime, + final_state: Optional[dict[str, Any]] = None, + state_delta: Optional[dict[str, Any]] = None, + error: Optional[str] = None, + ) -> Event: + """Create a graph completion event.""" + end_time = datetime.now() + duration_ms = self._calc_duration_ms(start_time, end_time) + phase = ExecutionPhase.ERROR.value if error else ExecutionPhase.COMPLETE.value + + if error: + text = f"Graph execution failed after {total_steps} steps: {error}" + else: + text = f"Graph execution completed in {total_steps} steps ({duration_ms:.1f}ms)" + + return self._build_event( + text=text, + state_delta={ + GRAPH_EXECUTION_KEY_PHASE: phase, + GRAPH_EXECUTION_KEY_TOTAL_STEPS: total_steps, + GRAPH_EXECUTION_KEY_TOTAL_DURATION_MS: duration_ms, + GRAPH_EXECUTION_KEY_START_TIME: start_time.isoformat(), + GRAPH_EXECUTION_KEY_END_TIME: end_time.isoformat(), + GRAPH_EXECUTION_KEY_FINAL_STATE_KEYS: list((final_state or {}).keys()), + GRAPH_EXECUTION_KEY_STATE_DELTA_KEYS: list((state_delta or {}).keys()), + GRAPH_EXECUTION_KEY_ERROR: error, + }, + visible=True, # Graph completion is visible + object_type=GraphEventType.GRAPH_EXECUTION, + ) + + # ========================================================================= + # State Events + # ========================================================================= + + def state_update( + self, + updated_keys: list[str], + removed_keys: Optional[list[str]] = None, + state_size: int = 0, + ) -> Event: + """Create a state update event with structured metadata. + + Args: + updated_keys: Keys that were updated + removed_keys: Keys that were removed (optional) + state_size: Total size of the state + + Returns: + Event for state update + """ + # Create structured metadata + metadata = StateUpdateMetadata( + updated_keys=updated_keys, + removed_keys=removed_keys or [], + state_size=state_size, + ) + + # Store metadata in state_delta + state_delta: dict[str, Any] = {} + _store_metadata(state_delta, METADATA_KEY_STATE, metadata) + + text = f"State updated: {len(updated_keys)} keys" + if removed_keys: + text += f", {len(removed_keys)} removed" + + return self._build_event( + text=text, + state_delta=state_delta, + partial=True, + object_type=GraphEventType.GRAPH_STATE_UPDATE, + ) diff --git a/trpc_agent_sdk/dsl/graph/_events/_constants.py b/trpc_agent_sdk/dsl/graph/_events/_constants.py new file mode 100644 index 000000000..349154293 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_events/_constants.py @@ -0,0 +1,78 @@ +# 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. +"""Event constants and enums for graph execution events. + +This module contains all constants used for metadata keys, object types, +and execution phase enumerations for graph events. +""" + +from enum import Enum + +# ============================================================================= +# Metadata Key Constants (for StateDelta storage) +# ============================================================================= + +METADATA_KEY_NODE = "_node_metadata" +"""Metadata key for node execution metadata.""" + +METADATA_KEY_TOOL = "_tool_metadata" +"""Metadata key for tool execution metadata.""" + +METADATA_KEY_MODEL = "_model_metadata" +"""Metadata key for model execution metadata.""" + +METADATA_KEY_STATE = "_state_metadata" +"""Metadata key for state update metadata.""" + +# ============================================================================= +# Graph Execution State Delta Keys +# ============================================================================= + +GRAPH_EXECUTION_KEY_PHASE = "phase" +"""State delta key for graph execution phase.""" + +GRAPH_EXECUTION_KEY_TOTAL_STEPS = "total_steps" +"""State delta key for graph execution total steps.""" + +GRAPH_EXECUTION_KEY_TOTAL_DURATION_MS = "total_duration_ms" +"""State delta key for graph execution total duration in milliseconds.""" + +GRAPH_EXECUTION_KEY_START_TIME = "start_time" +"""State delta key for graph execution start time.""" + +GRAPH_EXECUTION_KEY_END_TIME = "end_time" +"""State delta key for graph execution end time.""" + +GRAPH_EXECUTION_KEY_FINAL_STATE_KEYS = "final_state_keys" +"""State delta key listing keys included in final state.""" + +GRAPH_EXECUTION_KEY_STATE_DELTA_KEYS = "state_delta_keys" +"""State delta key listing keys included in state delta.""" + +GRAPH_EXECUTION_KEY_ERROR = "error" +"""State delta key for graph execution error message.""" + +# ============================================================================= +# Event Object Types (for Event.object field) +# ============================================================================= + + +class GraphEventType(str, Enum): + """Graph event object type identifiers.""" + + GRAPH_EXECUTION = "graph.execution" + GRAPH_NODE_EXECUTION = "graph.node.execution" + GRAPH_NODE_START = "graph.node.start" + GRAPH_NODE_COMPLETE = "graph.node.complete" + GRAPH_NODE_ERROR = "graph.node.error" + GRAPH_STATE_UPDATE = "graph.state.update" + + +class ExecutionPhase(str, Enum): + """Execution phase identifiers.""" + START = "start" + COMPLETE = "complete" + ERROR = "error" diff --git a/trpc_agent_sdk/dsl/graph/_events/_helpers.py b/trpc_agent_sdk/dsl/graph/_events/_helpers.py new file mode 100644 index 000000000..cfe15afca --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_events/_helpers.py @@ -0,0 +1,154 @@ +# 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. +"""Event utility functions for working with graph events. + +This module provides helper functions for extracting information from +Event objects, as well as an EventUtils class that groups these as +static/class methods. +""" + +from typing import Optional +from typing import Type +from typing import TypeVar + +from trpc_agent_sdk.events import Event + +from ._constants import METADATA_KEY_MODEL +from ._constants import METADATA_KEY_NODE +from ._constants import METADATA_KEY_TOOL +from ._metadata import ModelExecutionMetadata +from ._metadata import NodeExecutionMetadata +from ._metadata import ToolExecutionMetadata + +T = TypeVar('T') + + +class EventUtils: + """Utility methods for working with graph events.""" + + @staticmethod + def is_graph_event(event: Event) -> bool: + """Check if an event is a graph execution event by object type.""" + return bool(event.object and event.object.startswith("graph.")) + + @staticmethod + def get_event_type(event: Event) -> Optional[str]: + """Get the graph event type from Event.object.""" + return event.object + + @staticmethod + def get_node_id(event: Event) -> Optional[str]: + """Get the node ID from a graph event. + + Extracts from structured metadata if available, otherwise from flat state_delta. + """ + if not event.actions or not event.actions.state_delta: + return None + + state_delta = event.actions.state_delta + + # Try structured metadata first + if METADATA_KEY_NODE in state_delta: + node_metadata = state_delta[METADATA_KEY_NODE] + if isinstance(node_metadata, dict): + return node_metadata.get("node_id") + + if METADATA_KEY_TOOL in state_delta: + tool_metadata = state_delta[METADATA_KEY_TOOL] + if isinstance(tool_metadata, dict): + return tool_metadata.get("node_id") + + if METADATA_KEY_MODEL in state_delta: + model_metadata = state_delta[METADATA_KEY_MODEL] + if isinstance(model_metadata, dict): + return model_metadata.get("node_id") + + # Fallback to legacy flat structure + return state_delta.get("node_id") + + @staticmethod + def get_duration_ms(event: Event) -> float: + """Get the duration in milliseconds from a graph event. + + Extracts from structured metadata if available, otherwise from flat state_delta. + """ + if not event.actions or not event.actions.state_delta: + return 0.0 + + state_delta = event.actions.state_delta + + # Try structured metadata first + for metadata_key in [METADATA_KEY_NODE, METADATA_KEY_TOOL, METADATA_KEY_MODEL]: + if metadata_key in state_delta: + metadata = state_delta[metadata_key] + if isinstance(metadata, dict): + duration = metadata.get("duration_ms") + if duration is not None: + return float(duration) + + # Fallback to legacy flat structure + return state_delta.get("duration_ms", 0.0) + + @classmethod + def get_metadata(cls, event: Event, key: str, metadata_cls: Type[T]) -> Optional[T]: + """Generic method to extract typed metadata from event. + + Args: + event: Event to extract metadata from + key: Metadata key constant + metadata_cls: Metadata dataclass type + + Returns: + Metadata instance if found, None otherwise + """ + if not event.actions or not event.actions.state_delta: + return None + + metadata_dict = event.actions.state_delta.get(key) + if metadata_dict and isinstance(metadata_dict, dict): + try: + return metadata_cls(**metadata_dict) + except (TypeError, ValueError): + return None + return None + + @classmethod + def get_node_metadata(cls, event: Event) -> Optional[NodeExecutionMetadata]: + """Extract NodeExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + NodeExecutionMetadata if found, None otherwise + """ + return cls.get_metadata(event, METADATA_KEY_NODE, NodeExecutionMetadata) + + @classmethod + def get_tool_metadata(cls, event: Event) -> Optional[ToolExecutionMetadata]: + """Extract ToolExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + ToolExecutionMetadata if found, None otherwise + """ + return cls.get_metadata(event, METADATA_KEY_TOOL, ToolExecutionMetadata) + + @classmethod + def get_model_metadata(cls, event: Event) -> Optional[ModelExecutionMetadata]: + """Extract ModelExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + ModelExecutionMetadata if found, None otherwise + """ + return cls.get_metadata(event, METADATA_KEY_MODEL, ModelExecutionMetadata) + + # Note: cache/pregel/checkpoint helpers are intentionally omitted. diff --git a/trpc_agent_sdk/dsl/graph/_events/_metadata.py b/trpc_agent_sdk/dsl/graph/_events/_metadata.py new file mode 100644 index 000000000..ed398f931 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_events/_metadata.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 Apache-2.0. +"""Structured metadata dataclasses for graph events. + +This module provides typed metadata classes that are embedded in Event +objects via the state_delta field. +""" + +from dataclasses import asdict +from dataclasses import dataclass +from dataclasses import field +from dataclasses import is_dataclass +from typing import Any +from typing import Optional +from typing import TypeVar + +from trpc_agent_sdk.events import Event + +from ._constants import ExecutionPhase +from ._constants import GRAPH_EXECUTION_KEY_PHASE +from ._constants import METADATA_KEY_MODEL +from ._constants import METADATA_KEY_NODE +from ._constants import METADATA_KEY_TOOL + +T = TypeVar("T") + + +def _extract_event_metadata(event: Event, metadata_key: str, metadata_cls: type[T]) -> Optional[T]: + """Extract typed metadata from event state_delta.""" + actions = getattr(event, "actions", None) + state_delta = getattr(actions, "state_delta", None) if actions is not None else None + if not isinstance(state_delta, dict): + return None + + metadata_dict = state_delta.get(metadata_key) + if not isinstance(metadata_dict, dict): + return None + + try: + normalized_metadata = dict(metadata_dict) + phase_value = normalized_metadata.get(GRAPH_EXECUTION_KEY_PHASE) + if isinstance(phase_value, str): + normalized_metadata[GRAPH_EXECUTION_KEY_PHASE] = ExecutionPhase(phase_value) + return metadata_cls(**normalized_metadata) + except (TypeError, ValueError): + return None + + +# ============================================================================= +# Structured Metadata Dataclasses +# ============================================================================= + + +@dataclass +class NodeExecutionMetadata: + """Metadata for node execution events. + + Provides detailed information about node execution including timing + and input/output tracking. + """ + node_id: str + """The unique identifier of the node.""" + + node_type: str + """The type of the node (function, llm, tool, agent, etc.).""" + + phase: ExecutionPhase + """The execution phase (start, complete, error).""" + + node_description: Optional[str] = None + """Optional description of the node from NodeConfig.""" + + start_time: Optional[str] = None + """ISO format start time of execution.""" + + end_time: Optional[str] = None + """ISO format end time of execution.""" + + duration_ms: float = 0.0 + """Execution duration in milliseconds.""" + + step_number: int = 0 + """The execution step number.""" + + input_keys: list[str] = field(default_factory=list) + """Keys of input state.""" + + output_keys: list[str] = field(default_factory=list) + """Keys of output state.""" + + error: Optional[str] = None + """Error message if execution failed.""" + + tool_calls: list[dict[str, Any]] = field(default_factory=list) + """Tool call information for tool nodes.""" + + model_name: Optional[str] = None + """Model name for LLM nodes.""" + + model_input: Optional[str] = None + """Input sent to LLM nodes.""" + + # Retry/cache metadata intentionally omitted in the simplified graph API. + + @classmethod + def from_event(cls, event: Event) -> Optional["NodeExecutionMetadata"]: + """Extract NodeExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + NodeExecutionMetadata if found, None otherwise + """ + return _extract_event_metadata(event, METADATA_KEY_NODE, cls) + + +@dataclass +class ToolExecutionMetadata: + """Metadata for tool execution events.""" + + tool_name: str + """Name of the tool being executed.""" + + tool_id: str + """Unique identifier of the tool call.""" + + node_id: str + """Node ID where the tool is executed.""" + + phase: ExecutionPhase + """Execution phase (start, complete, error).""" + + start_time: Optional[str] = None + """ISO format start time.""" + + end_time: Optional[str] = None + """ISO format end time.""" + + duration_ms: float = 0.0 + """Execution duration in milliseconds.""" + + input_args: Optional[str] = None + """Tool input arguments (truncated).""" + + output_result: Optional[str] = None + """Tool output result (truncated).""" + + error: Optional[str] = None + """Error message if execution failed.""" + + @classmethod + def from_event(cls, event: Event) -> Optional["ToolExecutionMetadata"]: + """Extract ToolExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + ToolExecutionMetadata if found, None otherwise + """ + return _extract_event_metadata(event, METADATA_KEY_TOOL, cls) + + +@dataclass +class ModelExecutionMetadata: + """Metadata for model (LLM) execution events.""" + + model_name: str + """Name of the model being executed.""" + + node_id: str + """Node ID where the model is executed.""" + + phase: ExecutionPhase + """Execution phase (start, complete, error).""" + + start_time: Optional[str] = None + """ISO format start time.""" + + end_time: Optional[str] = None + """ISO format end time.""" + + duration_ms: float = 0.0 + """Execution duration in milliseconds.""" + + input_text: Optional[str] = None + """Model input (messages or prompt, truncated).""" + + output_text: Optional[str] = None + """Model output result (truncated).""" + + error: Optional[str] = None + """Error message if execution failed.""" + + step_number: int = 0 + """The execution step number.""" + + @classmethod + def from_event(cls, event: Event) -> Optional["ModelExecutionMetadata"]: + """Extract ModelExecutionMetadata from an event. + + Args: + event: Event to extract metadata from + + Returns: + ModelExecutionMetadata if found, None otherwise + """ + return _extract_event_metadata(event, METADATA_KEY_MODEL, cls) + + +@dataclass +class StateUpdateMetadata: + """Metadata for state update events.""" + + updated_keys: list[str] = field(default_factory=list) + """Keys that were updated.""" + + removed_keys: list[str] = field(default_factory=list) + """Keys that were removed.""" + + state_size: int = 0 + """Total size of the state.""" + + +@dataclass +class CompletionMetadata: + """Metadata for graph completion events.""" + + total_steps: int + """Total number of steps executed.""" + + total_duration_ms: float + """Total execution duration in milliseconds.""" + + final_state_keys: int + """Number of keys in the final state.""" + + +# ============================================================================= +# Metadata Helper Functions +# ============================================================================= + + +def _store_metadata(state_delta: dict[str, Any], key: str, metadata: Any) -> None: + """Store metadata in state_delta as JSON.""" + if metadata is not None: + metadata_dict = asdict(metadata) if is_dataclass(metadata) else metadata + state_delta[key] = metadata_dict diff --git a/trpc_agent_sdk/dsl/graph/_graph_agent.py b/trpc_agent_sdk/dsl/graph/_graph_agent.py new file mode 100644 index 000000000..12e05c1de --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_graph_agent.py @@ -0,0 +1,514 @@ +# 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. +"""GraphAgent executor for TRPC-Agent. + +This module provides the GraphAgent class that executes compiled StateGraphs +as TRPC-Agent agents, integrating with the Runner and event streaming system. + +Enhanced to support graph execution events. +""" + +import asyncio +import json +from datetime import datetime +from typing import Any +from typing import AsyncGenerator +from typing import ClassVar +from typing import Optional +from typing import Union +from typing import cast +from typing_extensions import override + +from langgraph.types import Command +from langgraph.types import Interrupt +from pydantic import ConfigDict +from pydantic import model_validator + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + +from ._constants import METADATA_KEY_AGENT_NAME +from ._constants import METADATA_KEY_BRANCH +from ._constants import METADATA_KEY_INVOCATION_ID +from ._constants import METADATA_KEY_SESSION_ID +from ._constants import ROLE_MODEL +from ._constants import ROLE_USER +from ._constants import STATE_KEY_LAST_RESPONSE +from ._constants import STATE_KEY_LONG_RUNNING_PREFIX +from ._constants import STATE_KEY_MESSAGES +from ._constants import STATE_KEY_METADATA +from ._constants import STATE_KEY_NODE_RESPONSES +from ._constants import STATE_KEY_PENDING_INTERRUPT +from ._constants import STATE_KEY_PENDING_INTERRUPT_AUTHOR +from ._constants import STATE_KEY_PENDING_INTERRUPT_BRANCH +from ._constants import STATE_KEY_PENDING_INTERRUPT_ID +from ._constants import STATE_KEY_SESSION +from ._constants import STATE_KEY_USER_INPUT +from ._constants import STREAM_KEY_ACK +from ._constants import STREAM_KEY_EVENT +from ._constants import is_unsafe_state_key +from ._events import EventBuilder +from ._memory_saver import has_graph_internal_checkpoint_state +from ._memory_saver import strip_graph_internal_checkpoint_state +from ._state_graph import CompiledStateGraph + +_INTERRUPT_KEY = "__interrupt__" +GraphState = dict[str, Any] +GraphInput = Union[GraphState, Command] +RunnableConfigMap = dict[str, dict[str, Any]] + + +class GraphAgent(BaseAgent): + """Agent that executes a compiled StateGraph. + + GraphAgent wraps a CompiledStateGraph and provides integration with + the TRPC-Agent Runner, handling event streaming and state management. + + Supports: + - Graph execution completion events + - Composing nested agents through `StateGraph.add_agent_node(...)` + + Example: + >>> from trpc_agent_sdk.dsl.graph import StateGraph, GraphAgent, State, START, END + >>> + >>> class MyState(State): + ... result: str + >>> + >>> async def process(state: MyState) -> dict: + ... return {"result": "processed"} + >>> + >>> graph = StateGraph(MyState) + >>> graph.add_node("process", process) + >>> graph.add_edge(START, "process") + >>> graph.add_edge("process", END) + >>> + >>> agent = GraphAgent( + ... name="my_workflow", + ... description="Processes user requests", + ... graph=graph.compile() + ... ) + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + """Pydantic model config allowing arbitrary types for graph field.""" + + sub_agents: ClassVar[tuple[BaseAgent, ...]] = () + """GraphAgent does not expose BaseAgent `sub_agents` construction.""" + + graph: CompiledStateGraph + """The compiled StateGraph to execute.""" + + @model_validator(mode="before") + @classmethod + def _reject_sub_agents(cls, data: Any) -> Any: + if isinstance(data, dict) and "sub_agents" in data: + raise ValueError("GraphAgent does not accept `sub_agents`; compose nested agents with " + "`StateGraph.add_agent_node(...)`.") + return data + + @override + def get_subagents(self) -> list[AgentABC]: + """Expose graph node agents as direct children for traversal/cleanup.""" + return list(self.graph.source.agent_nodes.values()) + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Execute the graph and yield events. + + This method: + 1. Builds initial state from the invocation context + 2. Sets the invocation context on the graph for ctx-signature nodes + 3. Executes the graph with stream_mode=["updates", "custom"] + 4. Yields events emitted by nodes + 5. Emits state update events when state changes + 6. Emits graph execution completion event + + Args: + ctx: The invocation context from the runner + + Yields: + Event objects emitted by graph nodes + """ + # Cancellation checkpoint at method entry + await ctx.raise_if_cancelled() + + logger.debug(f"[{self.name}] Starting graph execution for invocation {ctx.invocation_id}") + + start_time = datetime.now() + step_count = 0 + final_state: GraphState = {} + error_message: Optional[str] = None + + # Create EventBuilder for this execution + event_builder = EventBuilder( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch or self.name, + ) + + # Build initial state from invocation context + initial_state = self._build_initial_state(ctx) + logger.debug(f"[{self.name}] Initial state keys: {list(initial_state.keys())}") + + # Build runnable config with invocation context for thread safety + runnable_config = self._build_runnable_config(ctx) + + interrupted = False + resume_command = self._extract_resume_command(ctx) + if resume_command is not None: + logger.debug(f"[{self.name}] Resuming graph from pending interrupt") + self._clear_pending_interrupt_state(ctx) + graph_input: GraphInput = resume_command + else: + graph_input = initial_state + + try: + # Execute with stream_mode=["updates", "custom"] + # "updates" is required for state to propagate between nodes + # "custom" enables EventEmitter streaming + async for mode, chunk in self.graph.astream( + graph_input, + runnable_config, + stream_mode=["updates", "custom"], + ): + # Cancellation checkpoint at each iteration + await ctx.raise_if_cancelled() + + if isinstance(chunk, dict): + interrupts = self._iter_interrupts(chunk) + else: + interrupts = [] + if interrupts: + interrupted = True + for interrupt in interrupts: + function_call_event, function_response_event, long_running_event = ( + self._create_interrupt_events(ctx, interrupt)) + yield function_call_event + await ctx.raise_if_cancelled() + yield function_response_event + await ctx.raise_if_cancelled() + yield long_running_event + await ctx.raise_if_cancelled() + continue + + if mode == "updates": + # Track state updates and emit state update events + if isinstance(chunk, dict): + # Normal state updates + updated_keys = [] + for node_name, node_output in chunk.items(): + if isinstance(node_output, dict): + # Collect updated keys + updated_keys.extend(key for key in node_output.keys() if isinstance(key, str)) + # Update final state + for key, value in node_output.items(): + if isinstance(key, str): + final_state[key] = value + logger.debug( + f"[{self.name}] Node '{node_name}' updated keys: {list(node_output.keys())}") + + # Emit state update event + if updated_keys: + state_update_event = event_builder.state_update( + updated_keys=updated_keys, + state_size=len(final_state), + ) + yield state_update_event + + step_count += 1 + elif mode == "custom": + # Yield events from custom stream + if isinstance(chunk, dict): + ack = chunk.get(STREAM_KEY_ACK) + if isinstance(ack, asyncio.Future): + if not ack.done(): + ack.set_result(True) + chunk_event = chunk.get(STREAM_KEY_EVENT) + if isinstance(chunk_event, Event): + yield chunk_event + # Cancellation checkpoint after yielding events + await ctx.raise_if_cancelled() + except Exception as e: + error_message = str(e) + logger.error(f"[{self.name}] Graph execution failed: {e}", exc_info=True) + finally: + # Interrupt pauses graph execution and should not emit graph completion. + if interrupted and error_message is None: + logger.debug(f"[{self.name}] Graph execution interrupted and waiting for resume") + return + + # Emit graph completion event. + safe_state = self._filter_safe_state(final_state) + completion_event = event_builder.graph_complete( + total_steps=step_count, + start_time=start_time, + final_state=safe_state, + state_delta=safe_state, + error=error_message, + ) + if completion_event.actions is not None: + # Persist any state deltas accumulated via InvocationContext.state. + # This is the primary persistence path for MemorySaver. + if ctx.actions and ctx.actions.state_delta: + completion_event.actions.state_delta.update(ctx.actions.state_delta) + completion_event.actions.state_delta.update(safe_state) + logger.debug(f"[{self.name}] Graph execution completed in {step_count} steps") + yield completion_event + + def _extract_resume_command(self, ctx: InvocationContext) -> Optional[Command]: + """Build resume command when the latest user event is a function response.""" + events = getattr(ctx.session, "events", []) + if not events: + return None + + last_event = events[-1] + if last_event.author != ROLE_USER: + return None + + function_responses = last_event.get_function_responses() + if not function_responses: + return None + + function_response = function_responses[0] + function_response_id = function_response.id + if not isinstance(function_response_id, str) or not function_response_id: + return None + + session_state = dict(ctx.session.state) if ctx.session.state else {} + pending_id = session_state.get(STATE_KEY_PENDING_INTERRUPT_ID) + if isinstance(pending_id, str) and pending_id: + if function_response_id != pending_id: + return None + + if not function_response_id.startswith(STATE_KEY_LONG_RUNNING_PREFIX): + return None + interrupt_id = function_response_id[len(STATE_KEY_LONG_RUNNING_PREFIX):] + if not interrupt_id: + return None + + return Command(resume={interrupt_id: function_response.response}) + + def _clear_pending_interrupt_state(self, ctx: InvocationContext) -> None: + """Clear interrupt marker state before resuming graph execution.""" + ctx.state[STATE_KEY_PENDING_INTERRUPT] = False + ctx.state[STATE_KEY_PENDING_INTERRUPT_ID] = None + ctx.state[STATE_KEY_PENDING_INTERRUPT_AUTHOR] = None + ctx.state[STATE_KEY_PENDING_INTERRUPT_BRANCH] = None + + @staticmethod + def _iter_interrupts(chunk: dict[str, Any]) -> list[Interrupt]: + """Extract interrupt objects from a stream chunk.""" + interrupt_data = chunk.get(_INTERRUPT_KEY) + if interrupt_data is None: + return [] + if isinstance(interrupt_data, Interrupt): + return [interrupt_data] + if isinstance(interrupt_data, (list, tuple)): + return [item for item in interrupt_data if isinstance(item, Interrupt)] + return [] + + def _create_interrupt_events( + self, + ctx: InvocationContext, + interrupt: Interrupt, + ) -> tuple[Event, Event, LongRunningEvent]: + """Create TRPC-native interrupt bridge events for graph pause/resume.""" + function_call_id, function_name, function_args = self._build_interrupt_function(interrupt) + pending_delta = { + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: function_call_id, + STATE_KEY_PENDING_INTERRUPT_AUTHOR: self.name, + STATE_KEY_PENDING_INTERRUPT_BRANCH: ctx.branch or self.name, + } + for key, value in pending_delta.items(): + ctx.state[key] = value + + function_call = FunctionCall( + id=function_call_id, + name=function_name, + args=function_args, + ) + function_call_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content( + role=ROLE_MODEL, + parts=[Part(function_call=function_call)], + ), + ) + function_call_event.long_running_tool_ids = {function_call.id} + if function_call_event.actions is not None and ctx.actions and ctx.actions.state_delta: + function_call_event.actions.state_delta.update(ctx.actions.state_delta) + + interrupt_response: dict[str, Any] + if isinstance(interrupt.value, dict): + interrupt_response = interrupt.value + else: + interrupt_response = {"desicion": interrupt.value} + + function_response = FunctionResponse( + id=function_call.id, + name=function_call.name, + response=interrupt_response, + ) + function_response_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content( + role=ROLE_USER, + parts=[Part(function_response=function_response)], + ), + ) + + long_running_event = LongRunningEvent( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + function_call=function_call, + function_response=function_response, + ) + + return function_call_event, function_response_event, long_running_event + + def _build_interrupt_function(self, interrupt: Interrupt) -> tuple[str, str, dict[str, Any]]: + """Build synthetic function call identity and args for interrupt bridging.""" + interrupt_id = interrupt.id + function_name = "graph_interrupt" + function_call_id = f"{STATE_KEY_LONG_RUNNING_PREFIX}{interrupt_id}" + + raw_args = interrupt.value + if isinstance(raw_args, dict): + function_args = {str(key): value for key, value in raw_args.items()} + else: + function_args = {"value": raw_args} + + return function_call_id, function_name, function_args + + def _build_initial_state(self, ctx: InvocationContext) -> GraphState: + """Build initial state from invocation context. + + Extracts STATE_KEY_MESSAGES from session events and identifies the last + STATE_KEY_USER_INPUT to populate the initial state. + + Args: + ctx: The invocation context + + Returns: + Dictionary containing initial state values + """ + # Find last STATE_KEY_USER_INPUT text first + user_input = "" + user_input_event = None + for event in reversed(ctx.session.events): + if event.author == "user" and event.content and event.content.parts: + for part in event.content.parts: + if part.text: + user_input = part.text + user_input_event = event + break + if user_input: + break + + state_base = dict(ctx.session.state) if ctx.session.state else {} + has_saved_checkpoint = has_graph_internal_checkpoint_state(state_base) + + # Bootstrap messages only when there is no saved checkpoint yet. + # Once checkpoint data exists, LangGraph memory owns message continuity. + messages = [] + if not has_saved_checkpoint: + for event in ctx.session.events: + if event.content: + # Skip the user input event - it will be added via STATE_KEY_USER_INPUT + if event is user_input_event: + continue + messages.append(event.content) + initial_state = cast(GraphState, strip_graph_internal_checkpoint_state(state_base)) + # Never place the live Session object in graph state. + # The Session can be accessed from InvocationContext (`ctx`) when needed, + # while state is checkpointed and serialized across steps. + initial_state.pop(STATE_KEY_SESSION, None) + + if has_saved_checkpoint: + # Avoid replaying full message history when checkpointing state exists. + initial_state.pop(STATE_KEY_MESSAGES, None) + else: + initial_state[STATE_KEY_MESSAGES] = messages + + initial_state.update({ + STATE_KEY_USER_INPUT: user_input, + STATE_KEY_METADATA: { + METADATA_KEY_INVOCATION_ID: ctx.invocation_id, + METADATA_KEY_BRANCH: ctx.branch or self.name, + METADATA_KEY_SESSION_ID: ctx.session.id, + METADATA_KEY_AGENT_NAME: self.name, + }, + }) + + # Built-ins should always exist for node logic consistency. + initial_state.setdefault(STATE_KEY_LAST_RESPONSE, None) + initial_state.setdefault(STATE_KEY_NODE_RESPONSES, {}) + + return initial_state + + def _build_runnable_config(self, ctx: InvocationContext) -> RunnableConfigMap: + """Build configuration for graph execution. + + Args: + ctx: The invocation context + + Returns: + Configuration dictionary with thread_id and invocation context + """ + config: RunnableConfigMap = { + "configurable": { + "thread_id": ctx.session.id, + "checkpoint_ns": self.name, + # Pass invocation context via configurable for thread-safe access in nodes + "invocation_context": ctx, + } + } + + return config + + def _filter_safe_state(self, state: GraphState) -> GraphState: + """Filter out non-serializable state keys. + + Uses the centralized UNSAFE_STATE_KEYS set from _define.py for + comprehensive filtering. + + Args: + state: State dictionary to filter + + Returns: + Dictionary with only serializable values + """ + safe_state: GraphState = {} + dropped_keys = [] + for key, value in state.items(): + if not is_unsafe_state_key(key): + try: + # Quick serialization check + json.dumps(value, default=str) + safe_state[key] = value + except (TypeError, ValueError): + dropped_keys.append(key) + else: + dropped_keys.append(key) + + if dropped_keys: + logger.debug(f"[{self.name}] Dropped unsafe/non-serializable state keys: {dropped_keys}") + + return safe_state diff --git a/trpc_agent_sdk/dsl/graph/_interrupt.py b/trpc_agent_sdk/dsl/graph/_interrupt.py new file mode 100644 index 000000000..2a03b6be6 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_interrupt.py @@ -0,0 +1,28 @@ +# 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. +"""Graph-level interrupt API. + +This wraps LangGraph interrupt so graph users can stay within trpc_agent_dsl APIs. +""" + +from typing import TypeVar +from typing import cast + +from langgraph.types import interrupt as _langgraph_interrupt + +T = TypeVar("T") + + +def interrupt(value: T) -> T: + """Interrupt graph execution and return resume payload on continuation. + + Args: + value: Payload exposed to the client while execution is interrupted. + + Returns: + Resume payload when graph execution continues. + """ + return cast(T, _langgraph_interrupt(value)) diff --git a/trpc_agent_sdk/dsl/graph/_memory_saver.py b/trpc_agent_sdk/dsl/graph/_memory_saver.py new file mode 100644 index 000000000..b3aaa04bf --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_memory_saver.py @@ -0,0 +1,514 @@ +# 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. +"""LangGraph checkpointer backed by trpc-agent SessionService state. + +This module provides a BaseCheckpointSaver implementation that stores +LangGraph checkpoints inside trpc-agent Session.state. Because Session.state is +persisted by SessionService backends (in-memory/redis/sql), checkpoint data can +survive process restarts when the selected backend persists session state. +""" + +from __future__ import annotations + +import base64 +from collections.abc import AsyncIterator +from collections.abc import Iterator +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.checkpoint.base import ChannelVersions +from langgraph.checkpoint.base import Checkpoint +from langgraph.checkpoint.base import CheckpointMetadata +from langgraph.checkpoint.base import CheckpointTuple +from langgraph.checkpoint.base import WRITES_IDX_MAP +from langgraph.checkpoint.base import get_checkpoint_id +from langgraph.checkpoint.base import get_checkpoint_metadata + +from ._constants import STATE_KEY_CHECKPOINTS +from ._constants import STATE_KEY_CHECKPOINT_BLOBS +from ._constants import STATE_KEY_CHECKPOINT_WRITES + +_INTERNAL_CHECKPOINT_KEYS = frozenset({ + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINT_BLOBS, +}) + + +@dataclass(frozen=True) +class MemorySaverOption: + """Configuration options for graph MemorySaver.""" + + # Standalone mode option. In runner mode, persistence is via state_delta. + auto_persist: bool = False + # Persist intermediate writes immediately in standalone mode. + persist_writes: bool = False + + +def has_graph_internal_checkpoint_state(state: dict[str, Any]) -> bool: + """Check whether state already contains graph checkpoint storage.""" + if not state: + return False + return any(k in state for k in _INTERNAL_CHECKPOINT_KEYS) + + +def strip_graph_internal_checkpoint_state(state: dict[str, Any]) -> dict[str, Any]: + """Return a shallow copy of state without internal checkpoint storage keys.""" + if not state: + return {} + if not has_graph_internal_checkpoint_state(state): + return dict(state) + filtered = dict(state) + for key in _INTERNAL_CHECKPOINT_KEYS: + filtered.pop(key, None) + return filtered + + +@dataclass +class _StorageContext: + state: dict[str, Any] + invocation_context: Optional[Any] = None + session: Optional[Any] = None + session_service: Optional[Any] = None + + +class MemorySaver(BaseCheckpointSaver[str]): + """LangGraph checkpoint saver that stores data in Session.state.""" + + def __init__( + self, + *, + auto_persist: bool = False, + persist_writes: bool = False, + serde=None, + ) -> None: + super().__init__(serde=serde) + self._auto_persist = auto_persist + self._persist_writes = persist_writes + # Fallback for direct usage without invocation_context/session_state. + self._fallback_state: dict[str, Any] = {} + self._thread_contexts: dict[str, _StorageContext] = {} + + def _get_storage_context(self, config: RunnableConfig | None) -> _StorageContext: + configurable = (config or {}).get("configurable", {}) + + # Preferred path: InvocationContext passed by GraphAgent. + invocation_context = configurable.get("invocation_context") + if invocation_context is not None: + session = getattr(invocation_context, "session", None) + session_service = getattr(invocation_context, "session_service", None) + if session is not None and isinstance(getattr(session, "state", None), dict): + storage = _StorageContext( + state=session.state, + invocation_context=invocation_context, + session=session, + session_service=session_service, + ) + thread_id = configurable.get("thread_id") + if isinstance(thread_id, str) and thread_id: + self._thread_contexts[thread_id] = storage + return storage + + # Secondary path: direct config injection for testing/advanced usage. + session_state = configurable.get("session_state") + if isinstance(session_state, dict): + storage = _StorageContext( + state=session_state, + invocation_context=None, + session=configurable.get("session"), + session_service=configurable.get("session_service"), + ) + thread_id = configurable.get("thread_id") + if isinstance(thread_id, str) and thread_id: + self._thread_contexts[thread_id] = storage + return storage + + return _StorageContext(state=self._fallback_state) + + async def _persist_if_needed(self, ctx: _StorageContext) -> None: + # Primary persistence path is runner SessionService via event.state_delta. + # Only use direct update_session for standalone scenarios without ctx. + if ctx.invocation_context is not None: + return + if not self._auto_persist: + return + if ctx.session is None or ctx.session_service is None: + return + await ctx.session_service.update_session(ctx.session) + + @staticmethod + def _mark_state_delta(ctx: _StorageContext, *root_keys: str) -> None: + invocation_context = ctx.invocation_context + if invocation_context is None: + return + state_proxy = getattr(invocation_context, "state", None) + if state_proxy is None: + return + for key in root_keys: + if key in ctx.state: + state_proxy[key] = ctx.state[key] + + @staticmethod + def _ensure_nested(root: dict[str, Any], *keys: str) -> dict[str, Any]: + node = root + for key in keys: + child = node.get(key) + if not isinstance(child, dict): + child = {} + node[key] = child + node = child + return node + + def _checkpoints_root(self, state: dict[str, Any]) -> dict[str, Any]: + return self._ensure_nested(state, STATE_KEY_CHECKPOINTS) + + def _writes_root(self, state: dict[str, Any]) -> dict[str, Any]: + return self._ensure_nested(state, STATE_KEY_CHECKPOINT_WRITES) + + def _blobs_root(self, state: dict[str, Any]) -> dict[str, Any]: + return self._ensure_nested(state, STATE_KEY_CHECKPOINT_BLOBS) + + @staticmethod + def _encode_typed(value: tuple[str, bytes]) -> dict[str, str]: + return { + "type": value[0], + "data": base64.b64encode(value[1]).decode("ascii"), + } + + @staticmethod + def _decode_typed(value: Any) -> tuple[str, bytes]: + if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], bytes): + return value + if not isinstance(value, dict): + raise ValueError(f"Invalid typed payload: {type(value).__name__}") + value_type = value.get("type") + value_data = value.get("data") + if not isinstance(value_type, str) or not isinstance(value_data, str): + raise ValueError("Invalid typed payload fields") + return (value_type, base64.b64decode(value_data.encode("ascii"))) + + def _load_blobs( + self, + state: dict[str, Any], + thread_id: str, + checkpoint_ns: str, + versions: ChannelVersions, + ) -> dict[str, Any]: + channel_values: dict[str, Any] = {} + blobs_by_ns = self._blobs_root(state).get(thread_id, {}).get(checkpoint_ns, {}) + for channel, version in versions.items(): + encoded_value = blobs_by_ns.get(channel, {}).get(str(version)) + if not encoded_value: + continue + typed = self._decode_typed(encoded_value) + if typed[0] == "empty": + continue + channel_values[channel] = self.serde.loads_typed(typed) + return channel_values + + def _get_pending_writes( + self, + state: dict[str, Any], + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str, + ) -> list[tuple[str, str, Any]]: + writes_by_task = self._writes_root(state).get(thread_id, {}).get(checkpoint_ns, {}).get(checkpoint_id, {}) + pending: list[tuple[str, str, Any]] = [] + for task_id, writes_for_task in writes_by_task.items(): + if not isinstance(writes_for_task, dict): + continue + for _, write_item in writes_for_task.items(): + if not isinstance(write_item, dict): + continue + channel = write_item.get("channel") + encoded_value = write_item.get("value") + if not isinstance(channel, str) or encoded_value is None: + continue + pending.append((task_id, channel, self.serde.loads_typed(self._decode_typed(encoded_value)))) + return pending + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + storage_ctx = self._get_storage_context(config) + state = storage_ctx.state + configurable = config["configurable"] + thread_id: str = configurable["thread_id"] + checkpoint_ns: str = configurable.get("checkpoint_ns", "") + + checkpoints_by_ns = self._checkpoints_root(state).get(thread_id, {}).get(checkpoint_ns, {}) + if not checkpoints_by_ns: + return None + + checkpoint_id = get_checkpoint_id(config) + checkpoint_entry = None + resolved_checkpoint_id = checkpoint_id + if checkpoint_id: + checkpoint_entry = checkpoints_by_ns.get(checkpoint_id) + else: + resolved_checkpoint_id = max(checkpoints_by_ns.keys()) + checkpoint_entry = checkpoints_by_ns.get(resolved_checkpoint_id) + + if not checkpoint_entry or resolved_checkpoint_id is None: + return None + + checkpoint_base: Checkpoint = self.serde.loads_typed(self._decode_typed(checkpoint_entry["checkpoint"])) + checkpoint: Checkpoint = { + **checkpoint_base, + "channel_values": + self._load_blobs( + state, + thread_id, + checkpoint_ns, + checkpoint_base["channel_versions"], + ), + } + metadata: CheckpointMetadata = self.serde.loads_typed(self._decode_typed(checkpoint_entry["metadata"])) + parent_checkpoint_id = checkpoint_entry.get("parent_checkpoint_id") + parent_config = None + if parent_checkpoint_id: + parent_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + + result_config = config + if not checkpoint_id: + result_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": resolved_checkpoint_id, + } + } + + return CheckpointTuple( + config=result_config, + checkpoint=checkpoint, + metadata=metadata, + parent_config=parent_config, + pending_writes=self._get_pending_writes(state, thread_id, checkpoint_ns, resolved_checkpoint_id), + ) + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + storage_ctx = self._get_storage_context(config) + state = storage_ctx.state + checkpoints_root = self._checkpoints_root(state) + + config_thread_id = config["configurable"]["thread_id"] if config else None + config_checkpoint_ns = config["configurable"].get("checkpoint_ns") if config else None + config_checkpoint_id = get_checkpoint_id(config) if config else None + before_checkpoint_id = get_checkpoint_id(before) if before else None + + thread_ids = [config_thread_id] if config_thread_id else list(checkpoints_root.keys()) + remaining = limit + + for thread_id in thread_ids: + checkpoints_by_thread = checkpoints_root.get(thread_id, {}) + for checkpoint_ns, checkpoints_by_id in checkpoints_by_thread.items(): + if config_checkpoint_ns is not None and checkpoint_ns != config_checkpoint_ns: + continue + + for checkpoint_id in sorted(checkpoints_by_id.keys(), reverse=True): + if config_checkpoint_id and checkpoint_id != config_checkpoint_id: + continue + if before_checkpoint_id and checkpoint_id >= before_checkpoint_id: + continue + + checkpoint_entry = checkpoints_by_id[checkpoint_id] + metadata: CheckpointMetadata = self.serde.loads_typed( + self._decode_typed(checkpoint_entry["metadata"])) + if filter and not all(metadata.get(k) == v for k, v in filter.items()): + continue + + if remaining is not None: + if remaining <= 0: + return + remaining -= 1 + + checkpoint_base: Checkpoint = self.serde.loads_typed( + self._decode_typed(checkpoint_entry["checkpoint"])) + checkpoint: Checkpoint = { + **checkpoint_base, + "channel_values": + self._load_blobs( + state, + thread_id, + checkpoint_ns, + checkpoint_base["channel_versions"], + ), + } + + parent_checkpoint_id = checkpoint_entry.get("parent_checkpoint_id") + parent_config = None + if parent_checkpoint_id: + parent_config = { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": parent_checkpoint_id, + } + } + + yield CheckpointTuple( + config={ + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + }, + checkpoint=checkpoint, + metadata=metadata, + parent_config=parent_config, + pending_writes=self._get_pending_writes(state, thread_id, checkpoint_ns, checkpoint_id), + ) + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + storage_ctx = self._get_storage_context(config) + state = storage_ctx.state + configurable = config["configurable"] + thread_id: str = configurable["thread_id"] + checkpoint_ns: str = configurable.get("checkpoint_ns", "") + + checkpoint_copy = checkpoint.copy() + channel_values = checkpoint_copy.pop("channel_values", {}) + + blobs_by_ns = self._ensure_nested(self._blobs_root(state), thread_id, checkpoint_ns) + for channel, version in new_versions.items(): + encoded = (self._encode_typed(self.serde.dumps_typed(channel_values[channel])) + if channel in channel_values else self._encode_typed(("empty", b""))) + self._ensure_nested(blobs_by_ns, channel)[str(version)] = encoded + + checkpoints_by_ns = self._ensure_nested(self._checkpoints_root(state), thread_id, checkpoint_ns) + checkpoints_by_ns[checkpoint["id"]] = { + "checkpoint": self._encode_typed(self.serde.dumps_typed(checkpoint_copy)), + "metadata": self._encode_typed(self.serde.dumps_typed(get_checkpoint_metadata(config, metadata))), + "parent_checkpoint_id": configurable.get("checkpoint_id"), + } + self._mark_state_delta( + storage_ctx, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_BLOBS, + ) + + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + storage_ctx = self._get_storage_context(config) + state = storage_ctx.state + configurable = config["configurable"] + thread_id: str = configurable["thread_id"] + checkpoint_ns: str = configurable.get("checkpoint_ns", "") + checkpoint_id: str = configurable["checkpoint_id"] + + writes_by_checkpoint = self._ensure_nested(self._writes_root(state), thread_id, checkpoint_ns, checkpoint_id) + writes_by_task = writes_by_checkpoint.get(task_id) + if not isinstance(writes_by_task, dict): + writes_by_task = {} + writes_by_checkpoint[task_id] = writes_by_task + + for idx, (channel, value) in enumerate(writes): + write_idx = WRITES_IDX_MAP.get(channel, idx) + write_idx_key = str(write_idx) + if write_idx >= 0 and write_idx_key in writes_by_task: + continue + writes_by_task[write_idx_key] = { + "channel": channel, + "value": self._encode_typed(self.serde.dumps_typed(value)), + "task_path": task_path, + } + self._mark_state_delta(storage_ctx, STATE_KEY_CHECKPOINT_WRITES) + + def delete_thread(self, thread_id: str) -> None: + storage_ctx = self._thread_contexts.pop(thread_id, None) + state = storage_ctx.state if storage_ctx is not None else self._fallback_state + checkpoints_root = self._checkpoints_root(state) + writes_root = self._writes_root(state) + blobs_root = self._blobs_root(state) + checkpoints_root.pop(thread_id, None) + writes_root.pop(thread_id, None) + blobs_root.pop(thread_id, None) + if storage_ctx is not None: + self._mark_state_delta( + storage_ctx, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINT_BLOBS, + ) + + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + return self.get_tuple(config) + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + for item in self.list(config, filter=filter, before=before, limit=limit): + yield item + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + next_config = self.put(config, checkpoint, metadata, new_versions) + await self._persist_if_needed(self._get_storage_context(config)) + return next_config + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + self.put_writes(config, writes, task_id, task_path) + if self._persist_writes: + await self._persist_if_needed(self._get_storage_context(config)) + + async def adelete_thread(self, thread_id: str) -> None: + storage_ctx = self._thread_contexts.get(thread_id) + self.delete_thread(thread_id) + if storage_ctx is not None: + await self._persist_if_needed(storage_ctx) diff --git a/trpc_agent_sdk/dsl/graph/_node_action/__init__.py b/trpc_agent_sdk/dsl/graph/_node_action/__init__.py new file mode 100644 index 000000000..00ef2a602 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/__init__.py @@ -0,0 +1,26 @@ +# 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. +"""Node action executors for graph nodes. + +This module provides the NodeAction classes that encapsulate execution logic +for different node types (LLM, Tool, Agent). +""" + +from ._agent import AgentNodeAction +from ._base import BaseNodeAction +from ._code import CodeNodeAction +from ._knowledge import KnowledgeNodeAction +from ._llm import LLMNodeAction +from ._mcp import MCPNodeAction + +__all__ = [ + "AgentNodeAction", + "BaseNodeAction", + "CodeNodeAction", + "KnowledgeNodeAction", + "LLMNodeAction", + "MCPNodeAction", +] diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_agent.py b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py new file mode 100644 index 000000000..adf1e7a77 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py @@ -0,0 +1,364 @@ +# 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. +"""Agent node action executor.""" + +import json +from typing import Any +from typing import Callable +from typing import Optional + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import Part + +from .._callbacks import NodeCallbackContext +from .._callbacks import NodeCallbacks +from .._constants import STATE_KEY_LAST_RESPONSE +from .._constants import STATE_KEY_MESSAGES +from .._constants import STATE_KEY_NODE_RESPONSES +from .._constants import STATE_KEY_USER_INPUT +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._node_config import NodeConfig +from .._state import State +from .._state_mapper import SubgraphResult +from ._base import BaseNodeAction + + +class AgentNodeAction(BaseNodeAction): + """Executes sub-agent invocation with isolated state. + + This class invokes the sub-agent's run_async method with an isolated + child state to prevent side effects on the parent state. + + Attributes: + agent: Sub-agent instance + node_config: Common node configuration + """ + + def __init__( + self, + node_id: str, + agent: BaseAgent, + node_config: NodeConfig, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + callback_ctx: Optional[NodeCallbackContext] = None, + callbacks: Optional[NodeCallbacks] = None, + isolated_messages: bool = False, + input_from_last_response: bool = False, + event_scope: Optional[str] = None, + input_mapper: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None, + output_mapper: Optional[Callable[[dict[str, Any], SubgraphResult], Optional[dict[str, Any]]]] = None, + ): + """Initialize the agent node action. + + Args: + node_id: Graph node ID + agent: Sub-agent instance + node_config: Common node configuration + writer: EventWriter for high-frequency streaming text + async_writer: AsyncEventWriter for lifecycle events + ctx: Optional invocation context + callback_ctx: Optional callback context from wrapper + callbacks: Optional merged callbacks from wrapper + isolated_messages: If True, child execution does not inherit parent message history. + input_from_last_response: If True, map parent last_response to child user_input. + event_scope: Optional branch scope segment for child agent events. + input_mapper: Optional mapper from parent state to child state. + output_mapper: Optional mapper from SubgraphResult to parent state update. + """ + super().__init__(node_id, writer, async_writer, ctx) + self.agent = agent + self.node_id = node_id + self.node_config = node_config + self.callback_ctx = callback_ctx + self.callbacks = callbacks + self.isolated_messages = isolated_messages + self.input_from_last_response = input_from_last_response + self.event_scope = event_scope + self.input_mapper = input_mapper + self.output_mapper = output_mapper + + async def execute(self, state: State) -> dict[str, Any]: + """Execute the sub-agent invocation. + + Args: + state: Current state + + Returns: + State update dictionary (delta pattern) + """ + if self.agent is None: + raise RuntimeError(f"Agent for node '{self.node_id}' is None.") + + # Build child state via input mapper. + if self.input_mapper: + child_state = self.input_mapper(dict(state)) + else: + child_state = dict(state) + + # Optionally map parent last response to child user input. + if self.input_from_last_response: + last_response = state.get(STATE_KEY_LAST_RESPONSE, "") + if last_response: + child_state[STATE_KEY_USER_INPUT] = last_response + + parent_ctx: Optional[InvocationContext] = self.ctx + + if parent_ctx is None: + raise RuntimeError( + f"Agent node '{self.name}' requires InvocationContext but none was set. " + "Pass context via config['configurable']['invocation_context'] when executing the graph.") + + child_scope = self.event_scope or self.agent.name + child_branch = f"{parent_ctx.branch}.{child_scope}" if parent_ctx.branch else child_scope + child_user_input = child_state.get(STATE_KEY_USER_INPUT, "") + + child_session = parent_ctx.session.model_copy(deep=True) + child_session.state = dict(child_state) + child_events = self._build_child_events(parent_ctx, child_user_input, child_branch) + if hasattr(child_session, "events"): + child_session.events = child_events + if self.isolated_messages: + child_session.state[STATE_KEY_MESSAGES] = [] + + child_user_content = None + if isinstance(child_user_input, str) and child_user_input: + child_user_content = Content( + role="user", + parts=[Part.from_text(text=child_user_input)], + ) + + # Create an isolated invocation context for child execution. + child_ctx = parent_ctx.model_copy( + update={ + "agent": self.agent, + "session": child_session, + "branch": child_branch, + "user_content": child_user_content, + "event_actions": EventActions(), + "callback_state": None, + "override_messages": None, + }, + deep=False, + ) + + # Execute agent/sub-agent chain (supports transfer_to_agent). + last_response = "" + final_state = dict(child_session.state) + raw_state_delta: dict[str, Any] = {} + try: + root_agent = self._resolve_root_agent(self.agent) + current_agent = self.agent + while True: + transfer_target: Optional[str] = None + transfer_requested = False + child_ctx.agent = current_agent + + async for event in current_agent.run_async(child_ctx): + await self._run_agent_event_callbacks(state, event) + + if (not event.partial) and hasattr(child_session, "events"): + child_session.events.append(event.model_copy(deep=True)) + + if event.actions and event.actions.state_delta: + delta = dict(event.actions.state_delta) + raw_state_delta.update(delta) + + if not self._is_graph_event(event): + final_state.update(delta) + + candidate = delta.get(STATE_KEY_LAST_RESPONSE, "") + 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 event.visible: + if event.actions and event.actions.transfer_to_agent: + raise ValueError("Agent transfer requested but invisible is not allowed.") + continue + + event_to_emit = event + if event.actions and event.actions.transfer_to_agent: + # Transfer is handled inside AgentNodeAction. Do not leak it to + # Runner, otherwise Runner may perform the transfer again. + event_to_emit = event.model_copy(deep=True) + if event_to_emit.actions: + event_to_emit.actions.transfer_to_agent = None + transfer_requested = True + transfer_target = event.actions.transfer_to_agent + self.writer.write_event(event_to_emit) + + if transfer_requested: + break + + if not transfer_requested: + break + + target_agent = self._resolve_transfer_target(root_agent, transfer_target) + if target_agent is None: + error_event = Event( + invocation_id=child_ctx.invocation_id, + author=current_agent.name, + error_message=f"Transfer target agent '{transfer_target}' not found", + error_code="transfer_target_not_found", + branch=child_ctx.branch, + ) + await self._run_agent_event_callbacks(state, error_event) + if hasattr(child_session, "events"): + child_session.events.append(error_event.model_copy(deep=True)) + if error_event.visible: + self.writer.write_event(error_event) + break + + child_ctx.branch = self._build_transferred_branch( + current_branch=child_ctx.branch, + current_agent=current_agent, + target_agent=target_agent, + root_agent=root_agent, + ) + current_agent = target_agent + + if not last_response: + candidate = final_state.get(STATE_KEY_LAST_RESPONSE, "") + if isinstance(candidate, str) and candidate: + last_response = candidate + + except Exception as e: + raise RuntimeError(f"Agent node '{self.name}' execution failed: {e}") from e + + if last_response: + final_state[STATE_KEY_LAST_RESPONSE] = last_response + + node_response: Any = last_response + structured_output: Any = None + if isinstance(self.agent, LlmAgent) and self.agent.output_schema is not None: + node_response = json.loads(last_response) + structured_output = node_response + + subgraph_result = SubgraphResult( + last_response=last_response, + final_state=final_state, + raw_state_delta=raw_state_delta, + structured_output=structured_output, + ) + + default_result = { + STATE_KEY_LAST_RESPONSE: last_response, + STATE_KEY_NODE_RESPONSES: { + self.node_id: node_response + }, + STATE_KEY_USER_INPUT: "", + } + + if self.output_mapper: + mapped = self.output_mapper(state, subgraph_result) + if mapped is None: + return {} + if not isinstance(mapped, dict): + raise TypeError(f"Output mapper for agent node '{self.node_id}' must return dict, " + f"got {type(mapped).__name__}.") + if STATE_KEY_USER_INPUT not in mapped: + mapped = dict(mapped) + mapped[STATE_KEY_USER_INPUT] = "" + return mapped + + return default_result + + def _build_child_events( + self, + parent_ctx: InvocationContext, + child_user_input: Any, + child_branch: str, + ) -> list[Event]: + parent_events = getattr(parent_ctx.session, "events", []) + if self.isolated_messages: + child_events: list[Event] = [] + else: + child_events = [event.model_copy(deep=True) for event in parent_events] + + if isinstance(child_user_input, str) and child_user_input: + child_events.append( + Event( + invocation_id=parent_ctx.invocation_id, + author="user", + branch=child_branch, + content=Content( + role="user", + parts=[Part.from_text(text=child_user_input)], + ), + )) + return child_events + + async def _run_agent_event_callbacks(self, state: State, event: Event) -> None: + if not self.callbacks or not self.callbacks.agent_event: + return + + callback_ctx = self.callback_ctx or NodeCallbackContext( + node_id=self.node_id, + node_name=self.node_id, + node_type="agent", + ) + for callback in self.callbacks.agent_event: + await callback(callback_ctx, state, event) + + @staticmethod + def _resolve_root_agent(agent: BaseAgent) -> BaseAgent: + root = getattr(agent, "root_agent", None) + if root is not None: + return root + return agent + + @staticmethod + def _resolve_transfer_target(root_agent: BaseAgent, target_name: Optional[str]) -> Optional[BaseAgent]: + if not target_name: + return None + find_agent = getattr(root_agent, "find_agent", None) + if callable(find_agent): + return find_agent(target_name) + if getattr(root_agent, "name", None) == target_name: + return root_agent + return None + + @staticmethod + def _build_transferred_branch( + *, + current_branch: Optional[str], + current_agent: BaseAgent, + target_agent: BaseAgent, + root_agent: BaseAgent, + ) -> str: + if current_agent.name == root_agent.name: + return f"{current_agent.name}.{target_agent.name}" + + if current_branch: + branch_parts = [root_agent.name] + agent = target_agent + agent_path: list[str] = [] + while agent is not None and agent != root_agent: + agent_path.insert(0, agent.name) + agent = agent.parent_agent + if agent == root_agent: + return ".".join(branch_parts + agent_path) + + return target_agent.name + + @staticmethod + def _is_graph_event(event: Event) -> bool: + """Check whether an event is a graph lifecycle/metadata event.""" + object_type = getattr(event, "object", None) + return isinstance(object_type, str) and object_type.startswith("graph.") diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_base.py b/trpc_agent_sdk/dsl/graph/_node_action/_base.py new file mode 100644 index 000000000..daacead72 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_base.py @@ -0,0 +1,75 @@ +# 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. +"""Base class for node action executors. + +This module provides the abstract base class for all node action types +(LLM, Tool, Agent). Each action type implements specific execution logic +while sharing common infrastructure. +""" + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext + +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._state import State + + +class BaseNodeAction(ABC): + """Base class for node action executors. + + Node actions encapsulate the execution logic for different node types. + Each action type (LLM, Tool, Agent) inherits from this class and + implements the execute method. + + Attributes: + name: Name of the node + writer: EventWriter for high-frequency streaming events + async_writer: AsyncEventWriter for lifecycle start/complete/error events + ctx: Optional invocation context + + Example: + >>> class CustomNodeAction(BaseNodeAction): + ... async def execute(self, state: State) -> dict[str, Any]: + ... self.writer.write_text("Processing...") + ... return {"result": "done"} + """ + + def __init__( + self, + name: str, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ): + """Initialize the node action. + + Args: + name: Name of the node + writer: EventWriter for high-frequency streaming events + async_writer: AsyncEventWriter for lifecycle events + ctx: Optional invocation context + """ + self.name = name + self.writer = writer + self.async_writer = async_writer + self.ctx = ctx + + @abstractmethod + async def execute(self, state: State) -> dict[str, Any]: + """Execute the node action and return state update. + + Args: + state: Current state + + Returns: + State update dictionary + """ + pass diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_code.py b/trpc_agent_sdk/dsl/graph/_node_action/_code.py new file mode 100644 index 000000000..a17e8108b --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_code.py @@ -0,0 +1,63 @@ +# 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. +"""Code node action executor.""" + +from typing import Any +from typing import Optional + +from trpc_agent_sdk.code_executors import BaseCodeExecutor +from trpc_agent_sdk.code_executors import CodeBlock +from trpc_agent_sdk.code_executors import CodeExecutionInput +from trpc_agent_sdk.context import InvocationContext + +from .._constants import STATE_KEY_LAST_RESPONSE +from .._constants import STATE_KEY_NODE_RESPONSES +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._state import State +from ._base import BaseNodeAction + + +class CodeNodeAction(BaseNodeAction): + """Execute static code and map execution output to state.""" + + def __init__( + self, + name: str, + code_executor: BaseCodeExecutor, + code: str, + language: str, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ): + super().__init__(name, writer, async_writer, ctx) + self.code = code + self.language = language + self.executor = code_executor + + async def execute(self, state: State) -> dict[str, Any]: + del state + if self.ctx is None: + raise RuntimeError( + f"Code node '{self.name}' requires InvocationContext but none was set. " + "Pass context via config['configurable']['invocation_context'] when executing the graph.") + + result = await self.executor.execute_code( + self.ctx, + CodeExecutionInput( + code_blocks=[CodeBlock(language=self.language, code=self.code)], + execution_id=self.ctx.session.id, + ), + ) + output_text = result.output or "" + state_update: dict[str, Any] = { + STATE_KEY_LAST_RESPONSE: output_text, + STATE_KEY_NODE_RESPONSES: { + self.name: output_text + }, + } + return state_update diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_knowledge.py b/trpc_agent_sdk/dsl/graph/_node_action/_knowledge.py new file mode 100644 index 000000000..0399f781d --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_knowledge.py @@ -0,0 +1,117 @@ +# 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. +"""Knowledge search node action executor.""" + +from typing import Any +from typing import Callable +from typing import Optional +from typing import Union + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.server.knowledge.tools import LangchainKnowledgeSearchTool + +from .._constants import STATE_KEY_LAST_RESPONSE +from .._constants import STATE_KEY_NODE_RESPONSES +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._state import State +from ._base import BaseNodeAction + + +class KnowledgeNodeAction(BaseNodeAction): + """Execute a knowledge search tool and map results into graph state.""" + + def __init__( + self, + name: str, + query: Union[str, Callable[[State], str]], + tool: LangchainKnowledgeSearchTool, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ): + super().__init__(name, writer, async_writer, ctx) + self.query = query + self.tool = tool + + def _resolve_query(self, state: State) -> str: + if callable(self.query): + value = self.query(state) + else: + value = self.query + if not isinstance(value, str): + return str(value) + return value + + async def _invoke_tool(self, query: str) -> Any: + if self.ctx is None: + raise RuntimeError( + f"Knowledge node '{self.name}' requires InvocationContext but none was set. " + "Pass context via config['configurable']['invocation_context'] when executing the graph.") + return await self.tool.run_async(tool_context=self.ctx, args={"query": query}) + + @staticmethod + def _normalize_documents(result: Any) -> tuple[list[dict[str, Any]], str]: + message = "" + raw_documents: Any = result + if isinstance(result, dict): + raw_documents = result.get("documents", []) + raw_message = result.get("message") + if isinstance(raw_message, str): + message = raw_message + + if not isinstance(raw_documents, list): + raw_documents = [] + + documents: list[dict[str, Any]] = [] + for item in raw_documents: + if isinstance(item, dict) and "text" in item: + doc = { + "text": item.get("text", ""), + "score": item.get("score", 0.0), + } + metadata = item.get("metadata") + if metadata is not None: + doc["metadata"] = metadata + documents.append(doc) + continue + + if isinstance(item, dict): + document_obj = item.get("document") + text = "" + metadata: Any = None + if isinstance(document_obj, dict): + text = str(document_obj.get("page_content", "")) + metadata = document_obj.get("metadata") + score = item.get("score", 0.0) + doc = { + "text": text, + "score": score, + } + if metadata is not None: + doc["metadata"] = metadata + documents.append(doc) + + return documents, message + + async def execute(self, state: State) -> dict[str, Any]: + query = self._resolve_query(state) + result = await self._invoke_tool(query) + documents, message = self._normalize_documents(result) + + payload: dict[str, Any] = {"documents": documents} + if message: + payload["message"] = message + + logger.info(f"Query: [{query}], Result: [{str(payload['documents'])[:500]}]") + + return { + STATE_KEY_LAST_RESPONSE: payload, + STATE_KEY_NODE_RESPONSES: { + self.name: payload + }, + } diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_llm.py b/trpc_agent_sdk/dsl/graph/_node_action/_llm.py new file mode 100644 index 000000000..631e4d2a4 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_llm.py @@ -0,0 +1,519 @@ +# 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. +"""LLM node action executor. + +This module provides the LLMNodeAction class for executing LLM calls +within graph nodes, implementing three-stage message selection. +""" + +import asyncio +import inspect +import json +import uuid +from typing import Any +from typing import Optional + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Part + +from .._constants import ROLE_MODEL +from .._constants import ROLE_USER +from .._constants import STATE_KEY_LAST_RESPONSE +from .._constants import STATE_KEY_LAST_RESPONSE_ID +from .._constants import STATE_KEY_LAST_TOOL_RESPONSE +from .._constants import STATE_KEY_MESSAGES +from .._constants import STATE_KEY_NODE_RESPONSES +from .._constants import STATE_KEY_ONE_SHOT_MESSAGES +from .._constants import STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE +from .._constants import STATE_KEY_USER_INPUT +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._state import State +from ._base import BaseNodeAction + + +class LLMNodeAction(BaseNodeAction): + """Executes LLM node with three-stage message selection. + + Implements the three-stage rule from trpc-agent-go: + 1. One-shot stage: Use STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE[name] or STATE_KEY_ONE_SHOT_MESSAGES (consumed) + 2. User-input stage: Use history + current STATE_KEY_USER_INPUT + 3. History stage: Just use conversation history + + Attributes: + model: LLM model instance + instruction: System instruction + tools: Available tools + generation_config: Optional generation configuration + """ + + def __init__( + self, + name: str, + model: LLMModel, + instruction: str, + tools: dict[str, Any], + *, + tool_parallel: bool = False, + max_tool_iterations: int = 8, + generation_config: Optional[GenerateContentConfig], + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ): + """Initialize the LLM node action. + + Args: + name: Node name + model: LLM model instance + instruction: System instruction + tools: Available tools + tool_parallel: Whether to execute tool calls in parallel within one round + max_tool_iterations: Maximum model->tool loop rounds + generation_config: Optional generation configuration + writer: EventWriter for high-frequency streaming text + async_writer: AsyncEventWriter for lifecycle events + ctx: Optional invocation context + """ + super().__init__(name, writer, async_writer, ctx) + self.model = model + self.instruction = instruction + self.tools = tools + self.tool_parallel = tool_parallel + self.max_tool_iterations = max_tool_iterations + self.generation_config = generation_config + + def _convert_foreign_tool_messages(self, messages: list[Content]) -> list[Content]: + """Convert function_call/function_response to text for tools not in self.tools. + + When message history contains tool interactions from previous nodes that used + different tools, we need to convert those to plain text to avoid API errors. + The API requires tool definitions when the message history contains tool_calls. + + This method: + 1. Collects all function_call IDs and their matching function_response + 2. For each function_call not in self.tools, converts it and its response to text + 3. Preserves the conversation context while avoiding format errors + + Args: + messages: List of Content messages that may contain function_call/function_response + + Returns: + New list of Content messages with foreign tool interactions converted to text + """ + if not messages: + return messages + + # Get set of tool names available to this node + available_tool_names = set(self.tools.keys()) if self.tools else set() + + # First pass: collect function_response by ID for matching + response_by_id: dict[str, tuple[int, int, FunctionResponse]] = {} + for msg_idx, msg in enumerate(messages): + if not msg.parts: + continue + for part_idx, part in enumerate(msg.parts): + if part.function_response and part.function_response.id: + response_by_id[part.function_response.id] = (msg_idx, part_idx, part.function_response) + + # Second pass: identify which function_calls need conversion + # Track (msg_idx, part_idx) pairs that need conversion + calls_to_convert: set[tuple[int, int]] = set() # (msg_idx, part_idx) + responses_to_convert: set[tuple[int, int]] = set() # (msg_idx, part_idx) + + for msg_idx, msg in enumerate(messages): + if not msg.parts: + continue + for part_idx, part in enumerate(msg.parts): + if part.function_call: + tool_name = part.function_call.name + # Check if this tool is NOT in our available tools + if tool_name not in available_tool_names: + calls_to_convert.add((msg_idx, part_idx)) + # Find matching response by ID + call_id = part.function_call.id + if call_id and call_id in response_by_id: + resp_msg_idx, resp_part_idx, _ = response_by_id[call_id] + responses_to_convert.add((resp_msg_idx, resp_part_idx)) + + # If nothing to convert, return original messages + if not calls_to_convert and not responses_to_convert: + return messages + + # Third pass: build new messages list with conversions + result: list[Content] = [] + for msg_idx, msg in enumerate(messages): + if not msg.parts: + result.append(msg) + continue + + new_parts: list[Part] = [] + for part_idx, part in enumerate(msg.parts): + if part.function_call and (msg_idx, part_idx) in calls_to_convert: + # Convert function_call to text + fc = part.function_call + args_str = json.dumps(fc.args, ensure_ascii=False) if fc.args else "{}" + text = f"[Tool Call: {fc.name}({args_str})]" + new_parts.append(Part.from_text(text=text)) + elif part.function_response and (msg_idx, part_idx) in responses_to_convert: + # Convert function_response to text + fr = part.function_response + resp_str = json.dumps(fr.response, ensure_ascii=False) if fr.response else "{}" + text = f"[Tool Response ({fr.name}): {resp_str}]" + new_parts.append(Part.from_text(text=text)) + else: + # Keep original part + new_parts.append(part) + + # Create new Content with converted parts + result.append(Content(role=msg.role, parts=new_parts)) + + return result + + def _build_generation_config(self) -> GenerateContentConfig: + """Build a fresh generation config to avoid cross-invocation mutation.""" + if self.generation_config is not None: + config = self.generation_config.model_copy(deep=True) + else: + config = GenerateContentConfig() + config.system_instruction = self.instruction + return config + + @staticmethod + def _extract_input_text(messages: list[Content]) -> str: + if not messages: + return "" + last_content = messages[-1] + if not last_content.parts: + return "" + for part in last_content.parts: + if part.text: + return part.text + return "" + + @staticmethod + def _collect_tool_calls(parts: list[Part]) -> list[FunctionCall]: + return [part.function_call for part in parts if part.function_call is not None] + + @staticmethod + def _build_response_content(response_parts: list[Part], response_text: str) -> Optional[Content]: + if response_parts: + return Content(role=ROLE_MODEL, parts=response_parts) + if response_text: + return Content(role=ROLE_MODEL, parts=[Part.from_text(text=response_text)]) + return None + + async def _run_model_round( + self, + *, + messages: list[Content], + ctx: Optional[InvocationContext], + ) -> tuple[str, str, list[Part]]: + request = LlmRequest( + model=self.model.name, + contents=messages, + config=self._build_generation_config(), + ) + + if self.tools: + request.append_tools(list(self.tools.values())) + + input_text = self._extract_input_text(messages) + await self.async_writer.write_model_start(self.model.name, input_text) + + try: + response_text = "" + response_id = "" + response_parts: list[Part] = [] + + logger.debug(f"[{self.name}] Calling LLM model: {self.model.name}") + async for llm_response in self.model.generate_async(request, stream=True, ctx=ctx): + if llm_response.response_id: + response_id = llm_response.response_id + + if not llm_response.content or not llm_response.content.parts: + continue + + if llm_response.partial: + for part in llm_response.content.parts: + if part.text: + self.writer.write_text(part.text, partial=True) + continue + + response_parts = list(llm_response.content.parts) + text_parts = [part.text for part in response_parts if part.text] + response_text = "".join(text_parts) + + logger.debug(f"[{self.name}] LLM response received ({len(response_text)} chars)") + await self.async_writer.write_model_complete( + self.model.name, + input_text=input_text, + output_text=response_text, + ) + return response_text, response_id, response_parts + except Exception as e: + logger.error(f"[{self.name}] LLM node failed: {e}", exc_info=True) + await self.async_writer.write_model_complete(self.model.name, input_text=input_text, error=str(e)) + raise RuntimeError(f"LLM node '{self.name}' failed: {e}") from e + + async def _invoke_tool( + self, + *, + tool: Any, + tool_args: dict[str, Any], + ctx: InvocationContext, + ) -> Any: + run_async = getattr(tool, "run_async", None) + if callable(run_async): + try: + signature = inspect.signature(run_async) + except (TypeError, ValueError): + signature = None + + if signature and "tool_context" in signature.parameters: + return await run_async(tool_context=ctx, args=tool_args) + return await run_async(tool_args) + + run = getattr(tool, "run", None) + if callable(run): + return run(tool_args) + + if asyncio.iscoroutinefunction(tool): + return await tool(tool_args) + + if callable(tool): + return tool(tool_args) + + raise TypeError(f"Tool '{type(tool).__name__}' is not callable") + + async def _execute_tool_round( + self, + *, + function_calls: list[FunctionCall], + ctx: InvocationContext, + ) -> tuple[list[Content], str]: + normalized_calls: list[dict[str, Any]] = [] + for function_call in function_calls: + tool_name = function_call.name if isinstance(function_call.name, str) else "" + raw_tool_args = function_call.args + tool_args = raw_tool_args if isinstance(raw_tool_args, dict) else {} + tool_call_id = function_call.id if isinstance(function_call.id, str) and function_call.id else "" + if not tool_call_id: + tool_call_id = f"{tool_name}_{uuid.uuid4().hex[:8]}" + normalized_calls.append({ + "tool_name": tool_name, + "tool_args": tool_args, + "tool_call_id": tool_call_id, + }) + + # Emit visible function_call events so runner/UI can observe the tool plan. + for normalized_call in normalized_calls: + function_call_event_content = Content( + role=ROLE_MODEL, + parts=[ + Part(function_call=FunctionCall( + name=normalized_call["tool_name"], + id=normalized_call["tool_call_id"], + args=normalized_call["tool_args"], + )) + ], + ) + self.writer.write_content(function_call_event_content, partial=False) + + async def execute_single_tool(normalized_call: dict[str, Any]) -> dict[str, Any]: + tool_name = normalized_call["tool_name"] + tool_args = normalized_call["tool_args"] + tool_call_id = normalized_call["tool_call_id"] + input_args = json.dumps(tool_args, ensure_ascii=False) if tool_args else "" + await self.async_writer.write_tool_start(tool_name, tool_call_id, input_args) + + if tool_name not in self.tools: + error = f"Tool '{tool_name}' not found" + await self.async_writer.write_tool_complete(tool_name, tool_call_id, input_args=input_args, error=error) + return {"name": tool_name, "id": tool_call_id, "error": error} + + tool = self.tools[tool_name] + try: + result = await self._invoke_tool(tool=tool, tool_args=tool_args, ctx=ctx) + output_result = result if isinstance(result, str) else json.dumps( + result, ensure_ascii=False, default=str) + await self.async_writer.write_tool_complete( + tool_name, + tool_call_id, + input_args=input_args, + output_result=output_result, + ) + return {"name": tool_name, "id": tool_call_id, "result": result} + except Exception as e: + error = str(e) + await self.async_writer.write_tool_complete(tool_name, tool_call_id, input_args=input_args, error=error) + return {"name": tool_name, "id": tool_call_id, "error": error} + + if self.tool_parallel and len(normalized_calls) > 1: + tool_results = await asyncio.gather(*[execute_single_tool(call_info) for call_info in normalized_calls]) + else: + tool_results = [] + for normalized_call in normalized_calls: + tool_results.append(await execute_single_tool(normalized_call)) + + tool_messages: list[Content] = [] + last_tool_response = "" + for tool_result in tool_results: + if "error" in tool_result: + response_data = {"error": tool_result["error"]} + else: + response_data = tool_result["result"] + if isinstance(response_data, str): + last_tool_response = response_data + else: + last_tool_response = json.dumps(response_data, ensure_ascii=False, default=str) + + function_response = FunctionResponse( + name=tool_result["name"], + id=tool_result["id"], + response=response_data, + ) + function_response_content = Content(role=ROLE_USER, parts=[Part(function_response=function_response)]) + # Emit visible function_response events to mirror classic tool flow. + self.writer.write_content(function_response_content, partial=False) + tool_messages.append(function_response_content) + + return tool_messages, last_tool_response + + async def execute(self, state: State) -> dict[str, Any]: + """Execute the LLM node with three-stage message selection. + + Args: + state: Current state + + Returns: + State update dictionary + """ + # Determine which stage to execute + one_shot_by_node = state.get(STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, {}) + one_shot_global = state.get(STATE_KEY_ONE_SHOT_MESSAGES, []) + user_input = state.get(STATE_KEY_USER_INPUT, "") + history = list(state.get(STATE_KEY_MESSAGES, [])) + + # Track state updates for clearing consumed one-shot messages + clear_update: dict[str, Any] = {} + messages_to_use: list[Content] = [] + user_content_to_add: Optional[Content] = None # Track if we need to persist user input to history + + # Stage 1: Check STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE + if self.name in one_shot_by_node and one_shot_by_node[self.name]: + messages_to_use = list(one_shot_by_node[self.name]) + # Clear this node's one-shot messages from STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE + updated_by_node = dict(one_shot_by_node) + del updated_by_node[self.name] + clear_update[STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE] = updated_by_node + # Stage 1b: Check STATE_KEY_ONE_SHOT_MESSAGES + elif one_shot_global: + messages_to_use = list(one_shot_global) + # Clear global one-shot messages in STATE_KEY_ONE_SHOT_MESSAGES + clear_update[STATE_KEY_ONE_SHOT_MESSAGES] = [] + # Stage 2: STATE_KEY_USER_INPUT stage + elif user_input: + messages_to_use = list(history) + # Only add STATE_KEY_USER_INPUT if it's not already the last message in history + # This prevents duplication when history already contains the user message + # Check both with role=user and without role (from session events) + should_add_user_input = True + if messages_to_use: + last_msg = messages_to_use[-1] + # Check if last message is a user message (role is 'user' or None/unset) + is_user_msg = last_msg.role in (ROLE_USER, None, "") + if is_user_msg and last_msg.parts: + last_text = next((p.text for p in last_msg.parts if p.text), "") + if last_text == user_input: + should_add_user_input = False + + if should_add_user_input: + user_content_to_add = Content(role=ROLE_USER, parts=[Part.from_text(text=user_input)]) + messages_to_use.append(user_content_to_add) + # Clear STATE_KEY_USER_INPUT after use + clear_update[STATE_KEY_USER_INPUT] = "" + # Stage 3: History stage + else: + messages_to_use = list(history) + + # Convert function_call/function_response to text for tools not in self.tools + # This preserves conversation context while avoiding API errors when + # message history contains tool interactions from other nodes + conversation_messages = self._convert_foreign_tool_messages(messages_to_use) + messages_update: list[Content] = [] + if user_content_to_add is not None: + messages_update.append(user_content_to_add) + + final_response_text = "" + final_response_id = "" + last_tool_response = "" + + ctx = self.ctx + tool_iterations = 0 + while True: + response_text, response_id, response_parts = await self._run_model_round( + messages=conversation_messages, + ctx=ctx, + ) + + response_content = self._build_response_content(response_parts, response_text) + if response_content is not None: + conversation_messages.append(response_content) + messages_update.append(response_content) + + final_response_text = response_text + if response_id: + final_response_id = response_id + + function_calls = self._collect_tool_calls(response_parts) + if not function_calls or not self.tools: + break + + if tool_iterations >= self.max_tool_iterations: + logger.warning( + "[%s] Reached max_tool_iterations=%d, stop tool loop", + self.name, + self.max_tool_iterations, + ) + break + + if ctx is None: + raise RuntimeError(f"LLM node '{self.name}' requires InvocationContext for tool execution") + + tool_iterations += 1 + tool_messages, round_last_tool_response = await self._execute_tool_round( + function_calls=function_calls, + ctx=ctx, + ) + if tool_messages: + conversation_messages.extend(tool_messages) + messages_update.extend(tool_messages) + if round_last_tool_response: + last_tool_response = round_last_tool_response + + result: dict[str, Any] = { + STATE_KEY_MESSAGES: messages_update, + STATE_KEY_LAST_RESPONSE: final_response_text, + STATE_KEY_NODE_RESPONSES: { + self.name: final_response_text + }, + } + + if final_response_id: + result[STATE_KEY_LAST_RESPONSE_ID] = final_response_id + if last_tool_response: + result[STATE_KEY_LAST_TOOL_RESPONSE] = last_tool_response + + result.update(clear_update) + return result diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_mcp.py b/trpc_agent_sdk/dsl/graph/_node_action/_mcp.py new file mode 100644 index 000000000..3afbfa462 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_action/_mcp.py @@ -0,0 +1,98 @@ +# 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. +"""MCP node action executor.""" + +import json +from typing import Any +from typing import Optional +from typing import cast + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import MCPToolset + +from .._constants import STATE_KEY_LAST_RESPONSE +from .._constants import STATE_KEY_NODE_RESPONSES +from .._event_writer import AsyncEventWriter +from .._event_writer import EventWriter +from .._state import State +from ._base import BaseNodeAction + + +class MCPNodeAction(BaseNodeAction): + """Execute a selected MCP tool with request args from previous node response.""" + + def __init__( + self, + name: str, + mcp_toolset: MCPToolset, + selected_tool_name: str, + req_src_node: str, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ): + super().__init__(name, writer, async_writer, ctx) + self.mcp_toolset = mcp_toolset + self.selected_tool_name = selected_tool_name.strip() + self.req_src_node = req_src_node.strip() + + async def _resolve_selected_tool(self, ctx: InvocationContext) -> BaseTool: + tools = await self.mcp_toolset.get_tools(invocation_context=ctx) + for tool in tools: + if tool.name == self.selected_tool_name: + return tool + + raise ValueError( + f"MCP node '{self.name}' cannot find selected tool '{self.selected_tool_name}' in configured MCPToolset.") + + @staticmethod + def _try_parse_json(value: Any) -> Any: + if not isinstance(value, str): + return value + stripped = value.strip() + if not stripped: + return value + try: + return json.loads(stripped) + except json.JSONDecodeError: + return value + + def _resolve_request_args(self, state: State) -> dict[str, Any]: + node_responses = state[STATE_KEY_NODE_RESPONSES] + if not isinstance(node_responses, dict): + raise ValueError(f"MCP node '{self.name}' expects state[{STATE_KEY_NODE_RESPONSES!r}] to be a dict, " + f"got {type(node_responses).__name__}.") + + if self.req_src_node not in node_responses: + raise ValueError(f"MCP node '{self.name}' expects request payload in " + f"state[{STATE_KEY_NODE_RESPONSES!r}][{self.req_src_node!r}], but it is missing.") + + request_args = node_responses[self.req_src_node] + if not isinstance(request_args, dict): + raise ValueError(f"MCP node '{self.name}' expects request payload from node '{self.req_src_node}' " + f"to be a dict, got {type(request_args).__name__}.") + + return request_args + + async def execute(self, state: State) -> dict[str, Any]: + ctx = cast(InvocationContext, self.ctx) + + request_args = self._resolve_request_args(state) + selected_tool = await self._resolve_selected_tool(ctx) + + # Do not swallow MCP execution errors: fail fast so users can see invalid requests. + raw_response = await selected_tool.run_async(tool_context=ctx, args=request_args) + # Always close mcp resources after invoke + await self.mcp_toolset.close() + normalized_response = self._try_parse_json(raw_response) + + return { + STATE_KEY_LAST_RESPONSE: raw_response, + STATE_KEY_NODE_RESPONSES: { + self.name: normalized_response + }, + } diff --git a/trpc_agent_sdk/dsl/graph/_node_config.py b/trpc_agent_sdk/dsl/graph/_node_config.py new file mode 100644 index 000000000..7e3642303 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_node_config.py @@ -0,0 +1,53 @@ +# 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. +"""Common node configuration for graph nodes. + +NodeConfig only contains fields shared by all node kinds. +Node-specific behavior is configured at the corresponding add_*_node API. +""" + +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Optional + + +@dataclass +class NodeConfig: + """Common configuration shared by function/llm/tool/agent nodes. + + Attributes: + name: Human-readable name for the node. + description: Description of what the node does. + metadata: Arbitrary metadata dictionary. + """ + + name: Optional[str] = None + description: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) + + def to_metadata(self, *, node_type: str) -> dict[str, Any]: + """Convert NodeConfig to graph metadata. + + Args: + node_type: Node type string (function/llm/tool/agent). + + Returns: + Metadata dictionary suitable for graph engine adapters. + """ + result: dict[str, Any] = {} + + if self.name: + result["name"] = self.name + if self.description: + result["description"] = self.description + + if self.metadata: + result.update(self.metadata) + + result["node_type"] = node_type + + return result diff --git a/trpc_agent_sdk/dsl/graph/_state.py b/trpc_agent_sdk/dsl/graph/_state.py new file mode 100644 index 000000000..c6f925e7b --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_state.py @@ -0,0 +1,420 @@ +# 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. +"""State class and reducers for TRPC-Agent graph execution. + +This module defines the base State class that flows through graph nodes. +Unlike LangGraph's MessagesState, this uses a custom messages reducer +that handles google.genai.types.Content objects directly. +""" + +from copy import deepcopy +from typing import Annotated +from typing import Any +from typing import Optional +from typing import TypeVar +from typing_extensions import TypedDict + +from google.genai.types import Content + +from trpc_agent_sdk.agents._callback import AgentCallback +from trpc_agent_sdk.agents._callback import ModelCallback +from trpc_agent_sdk.agents._callback import ToolCallback +from trpc_agent_sdk.sessions import Session + +from ._callbacks import NodeCallbacks +from ._constants import METADATA_KEY_INVOCATION_ID +from ._constants import METADATA_KEY_SESSION_ID +from ._constants import STATE_KEY_LAST_RESPONSE +from ._constants import STATE_KEY_LAST_RESPONSE_ID +from ._constants import STATE_KEY_LAST_TOOL_RESPONSE +from ._constants import STATE_KEY_MESSAGES +from ._constants import STATE_KEY_METADATA +from ._constants import STATE_KEY_NODE_RESPONSES +from ._constants import STATE_KEY_ONE_SHOT_MESSAGES +from ._constants import STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE +from ._constants import STATE_KEY_SESSION +from ._constants import STATE_KEY_STEP_NUMBER +from ._constants import STATE_KEY_USER_INPUT + +# ============================================================================= +# Reducer Functions +# ============================================================================= + + +def merge_dict(existing: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[str, Any]: + """Reducer that merges dictionaries. + + New keys override existing keys (shallow merge). + + Args: + existing: Current dictionary + new: Dictionary with updates + + Returns: + Merged dictionary + """ + if existing is None: + existing = {} + if new is None: + return existing + result = dict(existing) + result.update(new) + return result + + +def append_list(existing: list[Any] | None, new: list[Any] | Any | None) -> list[Any]: + """Reducer that appends to lists. + + Args: + existing: Current list + new: Item(s) to append + + Returns: + Extended list + """ + if existing is None: + existing = [] + if new is None: + return existing + if isinstance(new, list): + return existing + new + return existing + [new] + + +def messages_reducer(existing: list[Content] | None, new: list[Content] | Content | None) -> list[Content]: + """Reducer for messages that handles google.genai.types.Content objects. + + This is similar to LangGraph's add_messages reducer but works with + google.genai.types.Content objects instead of LangChain BaseMessage. + + Mirrors the MessageReducer from trpc-agent-go/graph/state.go. + + Args: + existing: Current list of messages (Content objects) + new: New message(s) to append + + Returns: + Extended list of messages + """ + if existing is None: + existing = [] + if new is None: + return existing + if isinstance(new, list): + return existing + new + return existing + [new] + + +def _step_number_reducer(existing: int | None, new: int | None) -> int: + """Reducer for step_number that resolves concurrent writes safely. + + LangGraph may execute multiple nodes in the same super-step. When those + nodes all return STATE_KEY_STEP_NUMBER, use the largest observed value. + """ + if existing is None: + existing = 0 + if new is None: + return existing + return max(existing, new) + + +# ============================================================================= +# State Class +# ============================================================================= + + +class State(TypedDict, total=False): + """Base state for TRPC-Agent graph execution. + + Uses a custom messages reducer that handles google.genai.types.Content + objects directly, rather than LangChain's BaseMessage types. + + Users should extend this class to add custom state fields. + All built-in fields are optional (total=False). + + Built-in Fields: + STATE_KEY_MESSAGES: Chat history with custom reducer (handles Content objects) + STATE_KEY_USER_INPUT: Last user input text + STATE_KEY_LAST_RESPONSE: Most recent node response (text content) + STATE_KEY_LAST_RESPONSE_ID: ID of the last response (for tracking/referencing) + STATE_KEY_LAST_TOOL_RESPONSE: Result of the last tool execution + STATE_KEY_NODE_RESPONSES: Responses keyed by node name + STATE_KEY_ONE_SHOT_MESSAGES: Messages to include only once in next LLM call + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE: Node-specific one-shot messages + STATE_KEY_METADATA: TRPC-Agent metadata (invocation_id, branch, session_id, agent_name) + STATE_KEY_SESSION: Reference to the current Session object + STATE_KEY_CURRENT_NODE_ID: ID of the currently executing node + STATE_KEY_EXEC_CONTEXT: Execution context for advanced use cases + STATE_KEY_TOOL_CALLBACKS: Callbacks for tool execution + STATE_KEY_MODEL_CALLBACKS: Callbacks for model execution + STATE_KEY_AGENT_CALLBACKS: Callbacks for agent execution + STATE_KEY_NODE_CALLBACKS: Callbacks for node execution + + Three-Stage LLM Execution Rule: + The graph module implements a three-stage message selection rule: + 1. STATE_KEY_ONE_SHOT_MESSAGES: Consumed after single use (highest priority) + 2. STATE_KEY_USER_INPUT: Current turn's user input (if not empty) + 3. STATE_KEY_MESSAGES: Full conversation history (fallback) + + This is controlled by STATE_KEY_ONE_SHOT_MESSAGES and STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE. + + Example: + >>> class MyState(State): + ... counter: int + ... search_results: list[str] + ... classification: str + """ + # Core fields - STATE_KEY_MESSAGES now uses custom reducer instead of LangGraph's add_messages + messages: Annotated[list[Content], messages_reducer] + user_input: str + last_response: Optional[str] + last_response_id: Optional[str] # ID for tracking the last response + last_tool_response: Optional[str] # Result of last tool execution + + # Node output tracking + node_responses: Annotated[dict[str, Any], merge_dict] + + # One-shot messages (for three-stage LLM execution) + # These messages are consumed after single use + one_shot_messages: Annotated[list[Content], append_list] + one_shot_messages_by_node: Annotated[dict[str, list[Content]], merge_dict] + + # Metadata + metadata: Annotated[dict[str, Any], merge_dict] + + # Session and context + session: Optional[Session] # Session object reference + current_node_id: str + exec_context: Any # Execution context + + # Callbacks (stored in state for node access) + tool_callbacks: Optional[ToolCallback] + model_callbacks: Optional[ModelCallback] + agent_callbacks: Optional[AgentCallback] + node_callbacks: Optional[NodeCallbacks] + + # Step tracking + step_number: Annotated[int, _step_number_reducer] # Current step in graph execution + + +# ============================================================================= +# State Utility Class +# ============================================================================= + +T = TypeVar('T', bound=dict) + + +class StateUtils: + """Utility methods for working with graph state.""" + + @staticmethod + def clone(state: T) -> T: + """Create a deep copy of the state. + + Args: + state: State dictionary to clone + + Returns: + Deep copy of the state + """ + return deepcopy(state) + + @staticmethod + def get_user_input(state: dict[str, Any]) -> str: + """Get user input from state. + + Args: + state: State dictionary + + Returns: + User input string or empty string + """ + return state.get(STATE_KEY_USER_INPUT, "") + + @staticmethod + def get_last_response(state: dict[str, Any]) -> str: + """Get last response from state. + + Args: + state: State dictionary + + Returns: + Last response string or empty string + """ + return state.get(STATE_KEY_LAST_RESPONSE, "") + + @staticmethod + def get_node_response(state: dict[str, Any], node_id: str) -> Any: + """Get response from a specific node. + + Args: + state: State dictionary + node_id: ID of the node + + Returns: + Node response or None + """ + responses = state.get(STATE_KEY_NODE_RESPONSES, {}) + return responses.get(node_id) + + @staticmethod + def get_metadata(state: dict[str, Any]) -> dict[str, Any]: + """Get metadata from state. + + Args: + state: State dictionary + + Returns: + Metadata dictionary + """ + return state.get(STATE_KEY_METADATA, {}) + + @staticmethod + def get_invocation_id(state: dict[str, Any]) -> str: + """Get invocation ID from state metadata. + + Args: + state: State dictionary + + Returns: + Invocation ID or empty string + """ + metadata = StateUtils.get_metadata(state) + return metadata.get(METADATA_KEY_INVOCATION_ID, "") + + @staticmethod + def get_session_id(state: dict[str, Any]) -> str: + """Get session ID from state metadata. + + Args: + state: State dictionary + + Returns: + Session ID or empty string + """ + metadata = StateUtils.get_metadata(state) + return metadata.get(METADATA_KEY_SESSION_ID, "") + + @staticmethod + def get_messages(state: dict[str, Any]) -> list[Content]: + """Get messages from state. + + Args: + state: State dictionary + + Returns: + List of messages (Content from genai) + """ + return state.get(STATE_KEY_MESSAGES, []) + + @staticmethod + def get_session(state: dict[str, Any]) -> Any: + """Get session from state. + + Args: + state: State dictionary + + Returns: + Session object or None + """ + return state.get(STATE_KEY_SESSION) + + @staticmethod + def get_last_response_id(state: dict[str, Any]) -> str: + """Get last response ID from state. + + Args: + state: State dictionary + + Returns: + Last response ID or empty string + """ + return state.get(STATE_KEY_LAST_RESPONSE_ID, "") + + @staticmethod + def get_last_tool_response(state: dict[str, Any]) -> str: + """Get last tool response from state. + + Args: + state: State dictionary + + Returns: + Last tool response or empty string + """ + return state.get(STATE_KEY_LAST_TOOL_RESPONSE, "") + + @staticmethod + def get_one_shot_messages(state: dict[str, Any]) -> list[Content]: + """Get one-shot messages from state. + + One-shot messages are consumed after single use in LLM execution. + + Args: + state: State dictionary + + Returns: + List of one-shot messages + """ + return state.get(STATE_KEY_ONE_SHOT_MESSAGES, []) + + @staticmethod + def get_one_shot_messages_for_node(state: dict[str, Any], node_id: str) -> list[Content]: + """Get one-shot messages for a specific node. + + Args: + state: State dictionary + node_id: ID of the node + + Returns: + List of one-shot messages for the node + """ + by_node = state.get(STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, {}) + return by_node.get(node_id, []) + + @staticmethod + def consume_one_shot_messages(state: dict[str, Any], node_id: str) -> tuple[list[Content], dict[str, Any]]: + """Consume and return one-shot messages for a node. + + This retrieves global one-shot messages plus node-specific ones, + and returns a state update that clears them. + + Args: + state: State dictionary + node_id: ID of the node consuming the messages + + Returns: + Tuple of (messages, state_update) where state_update clears consumed messages + """ + global_msgs = state.get(STATE_KEY_ONE_SHOT_MESSAGES, []) + by_node = state.get(STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, {}) + node_msgs = by_node.get(node_id, []) + + # Combine messages + all_msgs = list(global_msgs) + list(node_msgs) + + # Build state update to clear consumed messages + state_update: dict[str, Any] = {} + if global_msgs: + state_update[STATE_KEY_ONE_SHOT_MESSAGES] = [] + if node_msgs: + # Remove this node's messages + updated_by_node = dict(by_node) + updated_by_node.pop(node_id, None) + state_update[STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE] = updated_by_node + + return all_msgs, state_update + + @staticmethod + def get_step_number(state: dict[str, Any]) -> int: + """Get current step number from state. + + Args: + state: State dictionary + + Returns: + Current step number (0 if not set) + """ + return state.get(STATE_KEY_STEP_NUMBER, 0) diff --git a/trpc_agent_sdk/dsl/graph/_state_graph.py b/trpc_agent_sdk/dsl/graph/_state_graph.py new file mode 100644 index 000000000..7940111a1 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_state_graph.py @@ -0,0 +1,817 @@ +# 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. +"""StateGraph wrapper for TRPC-Agent integration. + +This module provides StateGraph and CompiledStateGraph classes built on top +of LangGraph with TRPC-Agent-specific features. +""" + +import inspect +from typing import Any +from typing import Callable +from typing import Hashable +from typing import Optional +from typing import Type +from typing import Union + +from langgraph.config import get_config +from langgraph.config import get_stream_writer +from langgraph.errors import GraphInterrupt +from langgraph.graph import StateGraph as LangGraphStateGraph +from langgraph.types import Command + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.code_executors import BaseCodeExecutor +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.server.knowledge.tools import LangchainKnowledgeSearchTool +from trpc_agent_sdk.tools import MCPToolset +from trpc_agent_sdk.types import GenerateContentConfig + +from ._callbacks import NodeCallbackContext +from ._callbacks import NodeCallbacks +from ._callbacks import merge_callbacks +from ._constants import END +from ._constants import NODE_TYPE_AGENT +from ._constants import NODE_TYPE_CODE +from ._constants import NODE_TYPE_FUNCTION +from ._constants import NODE_TYPE_KNOWLEDGE +from ._constants import NODE_TYPE_LLM +from ._constants import NODE_TYPE_TOOL +from ._constants import START +from ._constants import STATE_KEY_METADATA +from ._constants import STATE_KEY_STEP_NUMBER +from ._event_writer import AsyncEventWriter +from ._event_writer import EventWriter +from ._memory_saver import MemorySaver +from ._memory_saver import MemorySaverOption +from ._node_action import AgentNodeAction +from ._node_action import CodeNodeAction +from ._node_action import KnowledgeNodeAction +from ._node_action import LLMNodeAction +from ._node_action import MCPNodeAction +from ._node_config import NodeConfig +from ._state import State +from ._state_mapper import SubgraphResult + +# Special node identifiers for graph routing +START_NODE = START +END_NODE = END + + +class StateGraph: + """Wrapper around LangGraph's StateGraph with TRPC-Agent integration. + + Provides a simplified API for building agent workflows with automatic + event emission and context injection. + + Supports node signatures: + 1. (state) - Simple node, no streaming or context needed + 2. (state, writer) - Node that streams partial results (sync writer) + 3. (state, async_writer) - Node that streams partial results (async writer) + 4. (state, ctx) - Node that needs full invocation context + 5. (state, writer, ctx) - Node that needs both (sync writer) + 6. (state, async_writer, ctx) - Node that needs both (async writer) + 7. (state, writer, async_writer) - Node that uses both writer types + 8. (state, writer, async_writer, ctx) - Node that uses both writers and context + + Example: + >>> graph = StateGraph(MyState) + >>> graph.add_node("processor", process_data) + >>> graph.add_node("responder", generate_response) + >>> graph.add_edge(START, "processor") + >>> graph.add_edge("processor", "responder") + >>> graph.add_edge("responder", END) + >>> compiled = graph.compile() + """ + + def __init__( + self, + state_schema: Type[State], + *, + callbacks: Optional[NodeCallbacks] = None, + ): + """Initialize the StateGraph. + + Args: + state_schema: TypedDict class defining the state structure + callbacks: Global callbacks applied to all nodes (optional) + """ + self._graph = LangGraphStateGraph(state_schema) + self._node_configs: dict[str, NodeConfig] = {} # Node configurations + self._node_callbacks: Optional[NodeCallbacks] = callbacks # Global callbacks + self._agent_nodes: dict[str, BaseAgent] = {} # Agent instances added via add_agent_node + + def add_node( + self, + name: str, + action: Callable, + *, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + _node_type: str = NODE_TYPE_FUNCTION, + ) -> "StateGraph": + """Add a node to the graph. + + The action function is automatically wrapped to: + 1. Inject EventWriter/AsyncEventWriter or InvocationContext based on signature + 2. Handle streaming via LangGraph's custom stream mode + + Supported signatures: + - async def node(state: State) -> dict + - async def node(state: State, writer: EventWriter) -> dict + - async def node(state: State, async_writer: AsyncEventWriter) -> dict + - async def node(state: State, ctx: InvocationContext) -> dict + - async def node(state: State, writer: EventWriter, ctx: InvocationContext) -> dict + - async def node(state: State, async_writer: AsyncEventWriter, ctx: InvocationContext) -> dict + - async def node(state: State, writer: EventWriter, async_writer: AsyncEventWriter) -> dict + - async def node(state: State, writer: EventWriter, async_writer: AsyncEventWriter, + ctx: InvocationContext) -> dict + + Args: + name: Unique name for the node. + action: Async function implementing node logic. + config: Common NodeConfig for this node (optional). + callbacks: Lifecycle callbacks for this node (optional). + _node_type: Internal node type value for metadata/events. + + Returns: + Self for method chaining + + Raises: + TypeError: If action is not an async function + + Example: + >>> from trpc_agent_sdk.dsl.graph import NodeConfig + >>> config = NodeConfig(name="Processor", description="Processes data") + >>> graph.add_node("process", process_action, config=config) + """ + if not inspect.iscoroutinefunction(action): + raise TypeError(f"Node action '{name}' must be async") + + # Create default config if not provided + if config is None: + config = NodeConfig(name=name) + elif config.name is None: + config.name = name + + # Store config + self._node_configs[name] = config + + # Create wrapper + wrapper = self._create_node_wrapper(name, action, config, _node_type, callbacks) + + metadata = config.to_metadata(node_type=_node_type) + + if metadata: + self._graph.add_node(name, wrapper, metadata=metadata or None) + else: + self._graph.add_node(name, wrapper) + + return self + + def _create_node_wrapper( + self, + name: str, + action: Callable, + config: NodeConfig, + node_type: str, + node_callbacks: Optional[NodeCallbacks], + ) -> Callable: + """Create a wrapper function for a node action. + + Args: + name: Node name + action: Original action function + config: Node configuration + node_type: Node type string for emitted metadata/events + node_callbacks: Node-level callbacks passed via add_node/add_*_node + + Returns: + Wrapped async function + """ + # Inspect signature to determine what to inject + sig = inspect.signature(action) + params = list(sig.parameters.keys()) + needs_writer = "writer" in params + needs_async_writer = "async_writer" in params + needs_ctx = "ctx" in params + needs_callback_ctx = "callback_ctx" in params + needs_callbacks = "callbacks" in params + + # Reference for closure + graph_ref = self + + async def wrapper(state: dict[str, Any]) -> dict[str, Any]: + """Wrapper that injects dependencies based on node signature.""" + stream_writer = get_stream_writer() + + # Get invocation context from runtime config (thread-safe) + runnable_config = get_config() + configurable = runnable_config.get("configurable", {}) + ctx: Optional[InvocationContext] = configurable.get("invocation_context") + + # Get metadata from state for event construction + metadata_dict = state.get(STATE_KEY_METADATA, {}) + invocation_id = metadata_dict.get("invocation_id", "") + branch = metadata_dict.get("branch", "") + session_id = metadata_dict.get("session_id", "") + + # Get and increment step number + current_step = state.get(STATE_KEY_STEP_NUMBER, 0) + + # Create writers for streaming + writer = EventWriter( + writer=stream_writer, + invocation_id=invocation_id, + author=name, + branch=branch, + ) + async_writer = AsyncEventWriter( + writer=stream_writer, + invocation_id=invocation_id, + author=name, + branch=branch, + ) + + # Create callback context with actual step number + callback_ctx = NodeCallbackContext( + node_id=name, + node_name=config.name or name, + node_type=node_type, + step_number=current_step, + invocation_id=invocation_id, + session_id=session_id, + invocation_context=ctx, + ) + + # Merge global and node-specific callbacks + callbacks = merge_callbacks(graph_ref._node_callbacks, node_callbacks) + + # Run before callbacks if any + if callbacks and callbacks.before_node: + for callback in callbacks.before_node: + result = await callback(callback_ctx, state) + if result is not None: + return result + + # Emit node start event + await async_writer.write_node_start( + node_id=name, + node_type=node_type, + node_description=config.description, + step_number=current_step, + input_keys=list(state.keys()), + ) + + try: + # Call original action with appropriate arguments + if needs_ctx and ctx is None: + raise RuntimeError( + f"Node '{name}' requires InvocationContext but none was set. " + "Pass context via config['configurable']['invocation_context'] when executing the graph.") + + kwargs: dict[str, Any] = {} + if needs_writer: + kwargs["writer"] = writer + if needs_async_writer: + kwargs["async_writer"] = async_writer + if needs_ctx: + kwargs["ctx"] = ctx + if needs_callback_ctx: + kwargs["callback_ctx"] = callback_ctx + if needs_callbacks: + kwargs["callbacks"] = callbacks + + if kwargs: + result = await action(state, **kwargs) + else: + result = await action(state) + + if result is None: + state_update = {} + elif isinstance(result, dict): + state_update = result + else: + raise TypeError(f"Node '{name}' must return a dict or None, got {type(result).__name__}") + + # Increment step number in state update + state_update[STATE_KEY_STEP_NUMBER] = current_step + 1 + + # Emit node complete event + await async_writer.write_node_complete( + node_id=name, + node_type=node_type, + node_description=config.description, + step_number=current_step, + output_keys=list(state_update.keys()) if state_update else [], + ) + + # Run after callbacks if any + if callbacks and callbacks.after_node: + for callback in callbacks.after_node: + modified = await callback(callback_ctx, state, state_update, None) + if modified is not None: + state_update = modified + + return state_update + + except GraphInterrupt: + # Interrupt is control flow for pause/resume, not node failure. + raise + except Exception as e: + # Emit node error event + await async_writer.write_node_error( + node_id=name, + error=str(e), + node_type=node_type, + node_description=config.description, + step_number=current_step, + ) + + # Run error callbacks if any + if callbacks and callbacks.on_error: + for callback in callbacks.on_error: + await callback(callback_ctx, state, e) + raise + + return wrapper + + def add_llm_node( + self, + name: str, + model: LLMModel, + instruction: str, + *, + tools: Optional[dict[str, Any]] = None, + tool_parallel: bool = False, + max_tool_iterations: int = 8, + generation_config: Optional[GenerateContentConfig] = None, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + ) -> "StateGraph": + """Add an LLM node that uses a model directly. + + This creates a node that: + 1. Builds messages from state + 2. Calls the model with instruction and tools + 3. Emits streaming events + 4. Updates state with response + + Args: + name: Unique name for the node + model: LLM model instance (from trpc_agent_sdk.models, e.g., OpenAIModel) + instruction: System instruction for the model + tools: Optional dict of tools available to the model. When provided, + tool calls are executed in-node and fed back to the model until + no function_call remains or max_tool_iterations is reached. + tool_parallel: Whether multiple tool calls in the same round run in parallel + max_tool_iterations: Maximum model->tool loop rounds per invocation + generation_config: Optional generation configuration (temperature, max_tokens, etc.) + config: Common NodeConfig for the node (optional) + callbacks: Lifecycle callbacks for this node (optional) + + Returns: + Self for method chaining + + Example: + >>> from trpc_agent_sdk.models import OpenAIModel + >>> from trpc_agent_sdk.types import GenerateContentConfig + >>> from trpc_agent_sdk.dsl.graph import NodeConfig + >>> model = OpenAIModel(model_name="deepseek-v3", api_key="...", base_url="...") + >>> gen_config = GenerateContentConfig(temperature=0.7, max_output_tokens=1000) + >>> config = NodeConfig(name="Classifier", description="Classifies intent") + >>> graph.add_llm_node("classify", model, "Classify user intent", + ... generation_config=gen_config, config=config) + """ + if max_tool_iterations <= 0: + raise ValueError("max_tool_iterations must be greater than 0") + + if config is None: + config = NodeConfig(name=name) + elif config.name is None: + config.name = name + + async def llm_action( + state: State, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ) -> dict: + action = LLMNodeAction( + name, + model, + instruction, + tools or {}, + tool_parallel=tool_parallel, + max_tool_iterations=max_tool_iterations, + generation_config=generation_config, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + return await action.execute(state) + + return self.add_node( + name, + llm_action, + config=config, + callbacks=callbacks, + _node_type=NODE_TYPE_LLM, + ) + + def add_code_node( + self, + name: str, + code_executor: BaseCodeExecutor, + code: str, + language: str, + *, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + ) -> "StateGraph": + """Add a code execution node. + + This creates a node that executes static code via code executor and + writes result text into built-in state keys. + Access per-node output via STATE_KEY_NODE_RESPONSES[name]. + + Args: + name: Unique name for the node. + code_executor: Pre-configured BaseCodeExecutor instance + (e.g. UnsafeLocalCodeExecutor, ContainerCodeExecutor). + code: Source code to execute. + language: Code language (python/bash/sh). + config: Optional NodeConfig. + callbacks: Optional node callbacks. + + Returns: + Self for method chaining. + """ + + async def code_action( + state: State, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ) -> dict[str, Any]: + action = CodeNodeAction( + name=name, + code_executor=code_executor, + code=code, + language=language, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + return await action.execute(state) + + return self.add_node( + name, + code_action, + config=config, + callbacks=callbacks, + _node_type=NODE_TYPE_CODE, + ) + + def add_knowledge_node( + self, + name: str, + query: Union[str, Callable[[State], str]], + tool: LangchainKnowledgeSearchTool, + *, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + ) -> "StateGraph": + """Add a standalone knowledge-search node. + + Args: + name: Unique name for the node. + query: Search query string or callable resolving query from state. + tool: Prebuilt LangchainKnowledgeSearchTool instance. + config: Optional NodeConfig. + callbacks: Optional node callbacks. + """ + if tool is None: + raise TypeError(f"Knowledge tool for node '{name}' must not be None.") + + if config is None: + config = NodeConfig(name=name) + elif config.name is None: + config.name = name + + async def knowledge_action( + state: State, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ) -> dict[str, Any]: + action = KnowledgeNodeAction( + name=name, + query=query, + tool=tool, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + return await action.execute(state) + + return self.add_node( + name, + knowledge_action, + config=config, + callbacks=callbacks, + _node_type=NODE_TYPE_KNOWLEDGE, + ) + + def add_mcp_node( + self, + name: str, + mcp_toolset: MCPToolset, + selected_tool_name: str, + req_src_node: str, + *, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + ) -> "StateGraph": + """Add a standalone MCP invocation node. + + This node executes one selected tool from ``mcp_toolset`` with args from: + ``state[STATE_KEY_NODE_RESPONSES][req_src_node]``. + + Usage note: + You must add a previous node that builds MCP request args and writes them + into ``state[STATE_KEY_NODE_RESPONSES][req_src_node]``. + MCP execution intentionally fails fast (errors are raised) so users can + see incorrect request payloads immediately. + + Args: + name: Unique name for the node. + mcp_toolset: Preconfigured MCPToolset instance. + selected_tool_name: Exact MCP tool name to invoke. + req_src_node: Upstream node id where request args are stored in node_responses. + config: Optional NodeConfig. + callbacks: Optional node callbacks. + """ + if mcp_toolset is None: + raise TypeError(f"MCP toolset for node '{name}' must not be None.") + + selected_name = selected_tool_name.strip() + if selected_name == "": + raise ValueError("selected_tool_name must be a non-empty string.") + + source_node_id = req_src_node.strip() + if source_node_id == "": + raise ValueError("req_src_node must be a non-empty string.") + + if config is None: + config = NodeConfig(name=name) + elif config.name is None: + config.name = name + + async def mcp_action( + state: State, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + ) -> dict[str, Any]: + action = MCPNodeAction( + name=name, + mcp_toolset=mcp_toolset, + selected_tool_name=selected_name, + req_src_node=source_node_id, + writer=writer, + async_writer=async_writer, + ctx=ctx, + ) + return await action.execute(state) + + return self.add_node( + name, + mcp_action, + config=config, + callbacks=callbacks, + _node_type=NODE_TYPE_TOOL, + ) + + def add_agent_node( + self, + node_id: str, + agent: BaseAgent, + *, + config: Optional[NodeConfig] = None, + callbacks: Optional[NodeCallbacks] = None, + isolated_messages: bool = False, + input_from_last_response: bool = False, + event_scope: Optional[str] = None, + input_mapper: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None, + output_mapper: Optional[Callable[[dict[str, Any], SubgraphResult], Optional[dict[str, Any]]]] = None, + ) -> "StateGraph": + """Add an agent node that invokes a sub-agent. + + Args: + node_id: Graph node ID. + agent: Agent instance to invoke. Must not be None. + config: Common NodeConfig for the node (optional) + callbacks: Lifecycle callbacks for this node (optional) + isolated_messages: If True, child execution does not inherit parent message history. + input_from_last_response: If True, map parent STATE_KEY_LAST_RESPONSE to child STATE_KEY_USER_INPUT. + event_scope: Optional branch scope segment for child agent events. + input_mapper: Function to transform parent state to child state. + output_mapper: Function to transform child result to parent state update. + Return None to skip state updates for this node. + + Returns: + Self for method chaining + + Example: + >>> from trpc_agent_sdk.dsl.graph import NodeConfig, StateMapper + >>> config = NodeConfig(name="Researcher", description="Research agent node") + >>> graph.add_agent_node( + ... "research", + ... agent=research_agent, + ... config=config, + ... isolated_messages=True, + ... input_mapper=StateMapper.pick("query", "context"), + ... ) + """ + if agent is None: + raise TypeError(f"Agent for node '{node_id}' must not be None.") + + if config is None: + config = NodeConfig(name=node_id) + elif config.name is None: + config.name = node_id + self._agent_nodes[node_id] = agent + + async def agent_action( + state: State, + writer: EventWriter, + async_writer: AsyncEventWriter, + ctx: Optional[InvocationContext] = None, + callback_ctx: Optional[NodeCallbackContext] = None, + callbacks: Optional[NodeCallbacks] = None, + ) -> dict: + action = AgentNodeAction( + node_id=node_id, + agent=agent, + node_config=config, + writer=writer, + async_writer=async_writer, + ctx=ctx, + callback_ctx=callback_ctx, + callbacks=callbacks, + isolated_messages=isolated_messages, + input_from_last_response=input_from_last_response, + event_scope=event_scope, + input_mapper=input_mapper, + output_mapper=output_mapper, + ) + return await action.execute(state) + + return self.add_node( + node_id, + agent_action, + config=config, + callbacks=callbacks, + _node_type=NODE_TYPE_AGENT, + ) + + @property + def agent_nodes(self) -> dict[str, BaseAgent]: + """Get agent nodes registered by add_agent_node. + + Returns: + Copy of {node_id: agent} mappings. + """ + return dict(self._agent_nodes) + + def add_edge(self, start: str, end: str) -> "StateGraph": + """Add a directed edge between nodes. + + Args: + start: Source node name (or START constant) + end: Target node name (or END constant) + + Returns: + Self for method chaining + """ + self._graph.add_edge(start, end) + return self + + def add_conditional_edges( + self, + source: str, + path: Callable[..., Hashable | list[Hashable]], + path_map: Optional[dict[Hashable, str]] = None, + ) -> "StateGraph": + """Add conditional edges based on a routing function. + + Args: + source: Source node name + path: Function that takes state and returns next node name(s) + path_map: Optional mapping from path return values to node names + + Returns: + Self for method chaining + """ + self._graph.add_conditional_edges(source, path, path_map) + return self + + def set_entry_point(self, key: str) -> "StateGraph": + """Set the entry point of the graph. + + Shorthand for add_edge(START, key). + + Args: + key: Name of the first node to execute + + Returns: + Self for method chaining + """ + return self.add_edge(START, key) + + def set_finish_point(self, key: str) -> "StateGraph": + """Set the finish point of the graph. + + Shorthand for add_edge(key, END). + + Args: + key: Name of the last node to execute + + Returns: + Self for method chaining + """ + return self.add_edge(key, END) + + def compile( + self, + *, + memory_saver_option: Optional[MemorySaverOption] = None, + ) -> "CompiledStateGraph": + """Compile the graph for execution. + + Args: + memory_saver_option: Optional MemorySaver settings. + + Returns: + CompiledStateGraph ready for execution + """ + option = memory_saver_option or MemorySaverOption() + memory_saver = MemorySaver( + auto_persist=option.auto_persist, + persist_writes=option.persist_writes, + ) + compiled = self._graph.compile(checkpointer=memory_saver) + return CompiledStateGraph(compiled, self) + + +class CompiledStateGraph: + """Wrapper around a LangGraph compiled graph. + + Maintains reference to the source StateGraph. + """ + + def __init__(self, compiled_graph, source_graph: StateGraph): + """Initialize the CompiledStateGraph. + + Args: + compiled_graph: LangGraph compiled graph + source_graph: The StateGraph this was compiled from + """ + self._compiled_graph = compiled_graph + self._source_graph = source_graph + + @property + def source(self) -> StateGraph: + """Get the source StateGraph.""" + return self._source_graph + + async def astream( + self, + graph_input: Union[dict[str, Any], Command], + config: dict[str, Any], + *, + stream_mode: list[str] | tuple[str, ...], + ): + """Stream execution results from the underlying compiled graph.""" + async for item in self._compiled_graph.astream( + graph_input, + config, + stream_mode=stream_mode, + ): + yield item + + def get_node_config(self, name: str) -> Optional[NodeConfig]: + """Get configuration for a specific node. + + Args: + name: Node name + + Returns: + NodeConfig or None + """ + return self._source_graph._node_configs.get(name) diff --git a/trpc_agent_sdk/dsl/graph/_state_mapper.py b/trpc_agent_sdk/dsl/graph/_state_mapper.py new file mode 100644 index 000000000..c70c46f26 --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_state_mapper.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 Apache-2.0. +"""State mapping utilities for subgraph nodes. + +This module provides utilities for transforming state between parent graphs +and child subgraphs, enabling flexible data flow control. + +Example: + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_USER_INPUT + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_METADATA + >>> + >>> # Pick specific fields from parent state + >>> graph.add_agent_node( + ... "researcher", + ... agent=research_agent, + ... input_mapper=StateMapper.pick("query", "context"), + ... output_mapper=StateMapper.merge_response("research_result"), + ... ) + >>> + >>> # Rename fields during mapping + >>> graph.add_agent_node( + ... "analyzer", + ... agent=analyzer_agent, + ... input_mapper=StateMapper.rename({STATE_KEY_USER_INPUT: "text_to_analyze"}), + ... ) +""" + +from dataclasses import dataclass +from dataclasses import field +from typing import Any +from typing import Callable + +# Type aliases for clarity +StateDict = dict[str, Any] + + +@dataclass +class SubgraphResult: + """Normalized result returned by an agent/subgraph node execution. + + Attributes: + last_response: Final text response observed from the child agent. + final_state: Reconstructed final child state after applying emitted deltas. + raw_state_delta: Raw merged state deltas emitted by the child run. + structured_output: Optional structured output extracted from child state. + """ + + last_response: str = "" + final_state: StateDict = field(default_factory=dict) + raw_state_delta: StateDict = field(default_factory=dict) + structured_output: Any = None + + +class StateMapper: + """Utilities for mapping state between parent and child graphs. + + Provides static methods for common state transformation patterns, + making it easy to control what data flows between graphs. + + Example: + >>> # Pick specific fields + >>> mapper = StateMapper.pick("query", "context") + >>> + >>> # Rename fields + >>> mapper = StateMapper.rename({"parent_field": "child_field"}) + >>> + >>> # Merge response into parent state + >>> mapper = StateMapper.merge_response("result_field") + """ + + @staticmethod + def pick(*fields: str) -> Callable[[StateDict], StateDict]: + """Create input mapper that picks specific fields from parent state. + + This is useful when you want to pass only a subset of the parent's + state to the child graph, keeping the child's interface clean. + + Args: + *fields: Field names to extract from parent state + + Returns: + Mapper function that extracts specified fields + + Example: + >>> # Only pass query and context to child + >>> input_mapper = StateMapper.pick("query", "context") + >>> graph.add_agent_node( + ... "researcher", + ... agent=research_agent, + ... input_mapper=input_mapper + ... ) + """ + + def mapper(state: StateDict) -> StateDict: + return {k: state[k] for k in fields if k in state} + + return mapper + + @staticmethod + def rename(mapping: dict[str, str]) -> Callable[[StateDict], StateDict]: + """Create input mapper that renames fields. + + This is useful when parent and child use different field names + for the same conceptual data. + + Args: + mapping: Dictionary mapping parent field names to child field names + Format: {parent_key: child_key} + + Returns: + Mapper function that renames fields + + Example: + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_USER_INPUT + >>> # Rename parent's "query" to child's STATE_KEY_USER_INPUT + >>> input_mapper = StateMapper.rename({ + ... "query": STATE_KEY_USER_INPUT, + ... "docs": "context" + ... }) + >>> graph.add_agent_node("processor", agent=processor_agent, input_mapper=input_mapper) + """ + + def mapper(state: StateDict) -> StateDict: + result = {} + for parent_key, child_key in mapping.items(): + if parent_key in state: + result[child_key] = state[parent_key] + return result + + return mapper + + @staticmethod + def merge_response(target_field: str) -> Callable[[StateDict, SubgraphResult], StateDict]: + """Create output mapper that stores child's response in a specific field. + + This is useful for capturing the child's result and storing it under + a specific key in the parent's state. + + Args: + target_field: Field name to store child's response in parent state + + Returns: + Mapper function that extracts response and returns state update + + Example: + >>> # Store child's response in "search_results" field + >>> output_mapper = StateMapper.merge_response("search_results") + >>> graph.add_agent_node( + ... "searcher", + ... agent=search_agent, + ... output_mapper=output_mapper + ... ) + """ + + def mapper(parent_state: StateDict, child_result: SubgraphResult) -> StateDict: + del parent_state + return {target_field: child_result.last_response} + + return mapper + + @staticmethod + def identity() -> Callable[[StateDict], StateDict]: + """Create identity mapper that passes state through unchanged. + + Returns: + Mapper function that returns state as-is + + Example: + >>> # Pass entire parent state to child + >>> input_mapper = StateMapper.identity() + """ + + def mapper(state: StateDict) -> StateDict: + return dict(state) + + return mapper + + @staticmethod + def combine(*mappers: Callable[[StateDict], StateDict]) -> Callable[[StateDict], StateDict]: + """Combine multiple input mappers into a single mapper. + + The result is a union of all mapper outputs. If multiple mappers + produce the same key, the last mapper's value wins. + + Args: + *mappers: Mapper functions to combine + + Returns: + Combined mapper function + + Example: + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_USER_INPUT + >>> from trpc_agent_sdk.dsl.graph import STATE_KEY_METADATA + >>> # Combine picking and renaming + >>> input_mapper = StateMapper.combine( + ... StateMapper.pick("context", STATE_KEY_METADATA), + ... StateMapper.rename({"query": STATE_KEY_USER_INPUT}) + ... ) + """ + + def mapper(state: StateDict) -> StateDict: + result = {} + for m in mappers: + result.update(m(state)) + return result + + return mapper + + @staticmethod + def filter_keys(predicate: Callable[[str], bool]) -> Callable[[StateDict], StateDict]: + """Create input mapper that filters keys based on a predicate. + + Args: + predicate: Function that takes a key and returns True to include it + + Returns: + Mapper function that filters state keys + + Example: + >>> # Only pass fields that start with "user_" + >>> input_mapper = StateMapper.filter_keys(lambda k: k.startswith("user_")) + """ + + def mapper(state: StateDict) -> StateDict: + return {k: v for k, v in state.items() if predicate(k)} + + return mapper + + @staticmethod + def exclude(*fields: str) -> Callable[[StateDict], StateDict]: + """Create input mapper that excludes specific fields. + + This is the opposite of `pick()` - it passes all fields except + the specified ones. + + Args: + *fields: Field names to exclude from parent state + + Returns: + Mapper function that excludes specified fields + + Example: + >>> # Pass everything except sensitive data + >>> input_mapper = StateMapper.exclude("api_key", "password") + """ + excluded = set(fields) + + def mapper(state: StateDict) -> StateDict: + return {k: v for k, v in state.items() if k not in excluded} + + return mapper diff --git a/trpc_agent_sdk/evaluation/__init__.py b/trpc_agent_sdk/evaluation/__init__.py new file mode 100644 index 000000000..8f614b4b3 --- /dev/null +++ b/trpc_agent_sdk/evaluation/__init__.py @@ -0,0 +1,396 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""TRPC Agent Evaluation Framework. + +A comprehensive evaluation framework for TRPC agents, adapted from +Google ADK Python evaluation framework. + +Main Components: +- agent_evaluator: Main entry point for evaluations +- EvalCase, EvalSet: Test case and set definitions +- Evaluators: Trajectory, ROUGE, and custom evaluators +- User Simulators: Static and LLM-backed user simulation +""" + +# Main evaluation entry point +from ._agent_evaluator import AgentEvaluator +from ._agent_evaluator import PassNC +from ._common import EvalBaseModel +from ._criterion_registry import CRITERION_REGISTRY +from ._criterion_registry import CriterionRegistry +from ._criterion_registry import CriterionType +from ._eval_callbacks import AfterEvaluateCaseArgs +from ._eval_callbacks import AfterEvaluateSetArgs +from ._eval_callbacks import AfterInferenceCaseArgs +from ._eval_callbacks import AfterInferenceSetArgs +from ._eval_callbacks import BeforeEvaluateCaseArgs +from ._eval_callbacks import BeforeEvaluateSetArgs +from ._eval_callbacks import BeforeInferenceCaseArgs +from ._eval_callbacks import BeforeInferenceSetArgs +from ._eval_callbacks import Callback +from ._eval_callbacks import CallbackFn +from ._eval_callbacks import CallbackPoint +from ._eval_callbacks import CallbackResult +from ._eval_callbacks import Callbacks +from ._eval_callbacks import CallbacksRunner +from ._eval_callbacks import EvalSetRunResult +from ._eval_case import ConversationScenario +from ._eval_case import EvalCase +from ._eval_case import EvalModeTrace +from ._eval_case import IntermediateData +from ._eval_case import IntermediateDataType +from ._eval_case import Invocation +from ._eval_case import InvocationEvent +from ._eval_case import InvocationEvents +from ._eval_case import SessionInput +from ._eval_case import StaticConversation +from ._eval_case import get_all_tool_calls +from ._eval_case import get_all_tool_responses +from ._eval_config import EvalConfig +from ._eval_criterion import FinalResponseCriterion +from ._eval_criterion import JSONCriterion +from ._eval_criterion import TextCriterion +from ._eval_criterion import ToolTrajectoryCriterion +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_metrics import Interval +from ._eval_metrics import MetricInfo +from ._eval_metrics import MetricValueInfo +from ._eval_metrics import PrebuiltMetrics +from ._eval_pass import pass_at_k +from ._eval_pass import pass_hat_k +from ._eval_result import EvalCaseResult +from ._eval_result import EvalCaseResultSummary +from ._eval_result import EvalCaseRunSummary +from ._eval_result import EvalMetricResult +from ._eval_result import EvalMetricResultDetails +from ._eval_result import EvalMetricResultPerInvocation +from ._eval_result import EvalMetricRunSummary +from ._eval_result import EvalMetricSummary +from ._eval_result import EvalSetAggregateResult +from ._eval_result import EvalSetResult +from ._eval_result import EvalSetResultSummary +from ._eval_result import EvalSetRunSummary +from ._eval_result import EvalStatusCounts +from ._eval_result import EvaluateResult +from ._eval_result import EvaluationResult +from ._eval_result import NamedScoreResult +from ._eval_result import PerInvocationResult +from ._eval_service_base import BaseEvalService +from ._eval_service_base import EvaluateConfig +from ._eval_service_base import EvaluateRequest +from ._eval_service_base import InferenceConfig +from ._eval_service_base import InferenceRequest +from ._eval_service_base import InferenceResult +from ._eval_service_base import InferenceStatus +from ._eval_session_service import EvalSessionService +from ._eval_set import EvalSet +from ._eval_set_results_manager_base import EvalSetResultsManager +from ._eval_set_results_manager_utils import build_eval_set_result_summary +from ._eval_set_results_manager_utils import create_eval_set_result +from ._eval_sets_manager_base import EvalSetsManager +from ._eval_sets_manager_utils import NotFoundError +from ._eval_sets_manager_utils import add_eval_case_to_eval_set +from ._eval_sets_manager_utils import delete_eval_case_from_eval_set +from ._eval_sets_manager_utils import get_eval_case_from_eval_set +from ._eval_sets_manager_utils import get_eval_set_from_app_and_id +from ._eval_sets_manager_utils import update_eval_case_in_eval_set +from ._evaluator_base import Evaluator +from ._evaluator_registry import EVALUATOR_REGISTRY +from ._evaluator_registry import EvaluatorRegistry +from ._final_response_evaluator import FinalResponseEvaluator +from ._in_memory_eval_sets_manager import InMemoryEvalSetsManager +from ._llm_criterion import DEFAULT_KNOWLEDGE_TOOL_NAMES +from ._llm_criterion import DEFAULT_NUM_SAMPLES +from ._llm_criterion import BUILT_IN_MODELS_AGGREGATORS +from ._llm_criterion import DEFAULT_MODELS_AGGREGATOR +from ._llm_criterion import DEFAULT_PARALLEL +from ._llm_criterion import WEIGHTED_MODELS_AGGREGATORS +from ._llm_criterion import JudgeModelOptions +from ._llm_criterion import LLMJudgeCriterion +from ._llm_criterion import Rubric +from ._llm_criterion import RubricContent +from ._llm_criterion import RubricScore +from ._llm_criterion import ScoreResult +from ._llm_criterion import get_llm_criterion_from_metric +from ._llm_criterion import sanitize_criterion_for_export +from ._llm_evaluator import InvocationsAggregatorFn +from ._llm_evaluator import LLMEvaluatorRegistry +from ._llm_evaluator import LLMFinalResponseEvaluator +from ._llm_evaluator import LLMRubricKnowledgeRecallEvaluator +from ._llm_evaluator import LLMRubricResponseEvaluator +from ._llm_evaluator import LLM_EVALUATOR_REGISTRY +from ._llm_evaluator import LLM_METRIC_NAMES +from ._llm_evaluator import MessagesConstructorFn +from ._llm_evaluator import ModelsAggregatorFn +from ._llm_evaluator import ResponseScorerFn +from ._llm_evaluator import SamplesAggregatorFn +from ._llm_judge import AllPassModelsAggregator +from ._llm_judge import AnyPassModelsAggregator +from ._llm_judge import AverageInvocationsAggregator +from ._llm_judge import AverageModelsAggregator +from ._llm_judge import DefaultMessagesConstructor +from ._llm_judge import DefaultResponseScorer +from ._llm_judge import FinalResponseOutput +from ._llm_judge import InvocationsAggregator +from ._llm_judge import LLMJudge +from ._llm_judge import MajorityPassModelsAggregator +from ._llm_judge import MajorityVoteSamplesAggregator +from ._llm_judge import MessagesConstructor +from ._llm_judge import ModelsAggregator +from ._llm_judge import ResponseScorer +from ._llm_judge import RubricItemOutput +from ._llm_judge import RubricJudgeOutput +from ._llm_judge import SamplesAggregator +from ._llm_judge import WeightedAverageModelsAggregator +from ._llm_judge import WeightedMajorityModelsAggregator +from ._llm_judge import get_builtin_models_aggregator +from ._local_eval_service import LocalEvalService +from ._remote_eval_service import CallAgent +from ._remote_eval_service import RemoteEvalService +from ._local_eval_set_results_manager import LocalEvalSetResultsManager +from ._local_eval_sets_manager import LocalEvalSetsManager +from ._local_eval_sets_manager import load_eval_set_from_file +from ._rouge_evaluator import RougeEvaluator +from ._static_user_simulator import StaticUserSimulator +from ._trajectory_evaluator import TrajectoryEvaluator +from ._user_simulator_base import BaseUserSimulatorConfig +from ._user_simulator_base import NextUserMessage +from ._user_simulator_base import Status +from ._user_simulator_base import UserSimulator +from ._user_simulator_provider import UserSimulatorProvider +from ._agent_optimizer import AgentOptimizer +from ._base_optimizer import BaseOptimizer +from ._optimize_config import FrameworkStopConfig +from ._optimize_config import GepaReflectiveAlgo +from ._optimize_config import OptimizeConfig +from ._optimize_config import OptimizeConfigFile +from ._optimize_config import load_optimize_config +from ._optimize_evaluator_call import EvaluationOutcome +from ._optimize_evaluator_call import run_evaluator +from ._optimize_evaluator_call import summarize_outcome +from ._optimize_gepa_reflective import GepaReflectiveOptimizer +from ._optimize_metric_info import build_metric_reference_doc +from ._optimize_metric_info import build_metric_section +from ._optimize_metric_info import build_reflection_prompt_template +from ._optimize_model_callable import DEFAULT_OPTIMIZE_MAX_TOKENS +from ._optimize_model_callable import DEFAULT_OPTIMIZE_TEMPERATURE +from ._optimize_model_options import OptimizeModelOptions +from ._optimize_registry import OPTIMIZER_REGISTRY +from ._optimize_registry import OptimizerRegistry +from ._optimize_reporter import OptimizeReporter +from ._optimize_reporter import RoundView +from ._optimize_reporter import RunHeader +from ._optimize_reporter import create_reporter +from ._optimize_result import FinishReason +from ._optimize_result import OptimizeResult +from ._optimize_result import RoundKind +from ._optimize_result import RoundRecord +from ._optimize_result import RunStatus +from ._optimize_result import StopReason +from ._target_prompt import TargetPrompt +from ._utils import EvalResultHandler +from ._utils import MetricRunRecord + +from . import _optimize_registrations # noqa: F401 # triggers algorithm registrations + +__all__ = [ + "AgentOptimizer", + "BaseOptimizer", + "DEFAULT_OPTIMIZE_MAX_TOKENS", + "DEFAULT_OPTIMIZE_TEMPERATURE", + "EvaluationOutcome", + "FinishReason", + "FrameworkStopConfig", + "GepaReflectiveAlgo", + "GepaReflectiveOptimizer", + "OPTIMIZER_REGISTRY", + "OptimizeConfig", + "OptimizeConfigFile", + "OptimizeModelOptions", + "OptimizeReporter", + "OptimizeResult", + "OptimizerRegistry", + "RoundKind", + "RoundRecord", + "RoundView", + "RunHeader", + "RunStatus", + "StopReason", + "build_metric_reference_doc", + "build_metric_section", + "build_reflection_prompt_template", + "create_reporter", + "run_evaluator", + "summarize_outcome", + "TargetPrompt", + "load_optimize_config", + "CRITERION_REGISTRY", + "CriterionRegistry", + "CriterionType", + "AfterEvaluateCaseArgs", + "AfterEvaluateSetArgs", + "AfterInferenceCaseArgs", + "AfterInferenceSetArgs", + "BeforeEvaluateCaseArgs", + "BeforeEvaluateSetArgs", + "BeforeInferenceCaseArgs", + "BeforeInferenceSetArgs", + "Callback", + "CallbackFn", + "CallbackPoint", + "CallbackResult", + "Callbacks", + "EvalSetRunResult", + "ConversationScenario", + "EvalCase", + "EvalModeTrace", + "IntermediateData", + "IntermediateDataType", + "Invocation", + "InvocationEvent", + "InvocationEvents", + "SessionInput", + "StaticConversation", + "get_all_tool_calls", + "get_all_tool_responses", + "EvalConfig", + "FinalResponseCriterion", + "JSONCriterion", + "TextCriterion", + "ToolTrajectoryCriterion", + "EvalMetric", + "EvalStatus", + "Interval", + "MetricInfo", + "MetricValueInfo", + "PrebuiltMetrics", + "EvalCaseResult", + "EvalCaseResultSummary", + "EvalCaseRunSummary", + "EvalMetricResult", + "EvalMetricResultDetails", + "EvalMetricResultPerInvocation", + "EvalMetricRunSummary", + "EvalMetricSummary", + "EvalSetAggregateResult", + "EvalSetResult", + "EvalSetResultSummary", + "EvalSetRunSummary", + "EvalStatusCounts", + "EvaluateResult", + "EvaluationResult", + "PerInvocationResult", + "NamedScoreResult", + "BaseEvalService", + "EvaluateConfig", + "EvaluateRequest", + "InferenceConfig", + "InferenceRequest", + "InferenceResult", + "EvalSet", + "EvalSetResultsManager", + "EvalSetsManager", + "Evaluator", + "EVALUATOR_REGISTRY", + "EvaluatorRegistry", + "InMemoryEvalSetsManager", + "LLMJudgeCriterion", + "DEFAULT_KNOWLEDGE_TOOL_NAMES", + "DEFAULT_NUM_SAMPLES", + "BUILT_IN_MODELS_AGGREGATORS", + "DEFAULT_MODELS_AGGREGATOR", + "DEFAULT_PARALLEL", + "WEIGHTED_MODELS_AGGREGATORS", + "RubricScore", + "ScoreResult", + "AverageInvocationsAggregator", + "DefaultMessagesConstructor", + "DefaultResponseScorer", + "InvocationsAggregator", + "MajorityVoteSamplesAggregator", + "MessagesConstructor", + "ResponseScorer", + "SamplesAggregator", + "AllPassModelsAggregator", + "AnyPassModelsAggregator", + "AverageModelsAggregator", + "MajorityPassModelsAggregator", + "ModelsAggregator", + "WeightedAverageModelsAggregator", + "WeightedMajorityModelsAggregator", + "get_builtin_models_aggregator", + "LocalEvalSetResultsManager", + "LocalEvalSetsManager", + "BaseUserSimulatorConfig", + "NextUserMessage", + "Status", + "UserSimulator", + "UserSimulatorProvider", + "AgentEvaluator", + "PassNC", + "FinalResponseEvaluator", + "InvocationsAggregatorFn", + "LLMFinalResponseEvaluator", + "LLMRubricKnowledgeRecallEvaluator", + "LLMRubricResponseEvaluator", + "LLM_EVALUATOR_REGISTRY", + "LLM_METRIC_NAMES", + "MessagesConstructorFn", + "ModelsAggregatorFn", + "ResponseScorerFn", + "SamplesAggregatorFn", + "LocalEvalService", + "RemoteEvalService", + "CallAgent", + "RougeEvaluator", + "StaticUserSimulator", + "TrajectoryEvaluator", + "EvalBaseModel", + "CallbacksRunner", + "pass_at_k", + "pass_hat_k", + "InferenceStatus", + "EvalSessionService", + "build_eval_set_result_summary", + "create_eval_set_result", + "NotFoundError", + "add_eval_case_to_eval_set", + "delete_eval_case_from_eval_set", + "get_eval_case_from_eval_set", + "get_eval_set_from_app_and_id", + "update_eval_case_in_eval_set", + "JudgeModelOptions", + "Rubric", + "RubricContent", + "get_llm_criterion_from_metric", + "sanitize_criterion_for_export", + "LLMEvaluatorRegistry", + "FinalResponseOutput", + "LLMJudge", + "RubricItemOutput", + "RubricJudgeOutput", + "load_eval_set_from_file", + "EvalResultHandler", + "MetricRunRecord", +] diff --git a/trpc_agent_sdk/evaluation/_agent_evaluator.py b/trpc_agent_sdk/evaluation/_agent_evaluator.py new file mode 100644 index 000000000..a2e47009d --- /dev/null +++ b/trpc_agent_sdk/evaluation/_agent_evaluator.py @@ -0,0 +1,960 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""TRPC Agent Evaluation Framework - Main Entry Point. + +This module provides the AgentEvaluator class, which serves as the primary interface +for evaluating TRPC agents against predefined test cases. + +Key Features: + - Agent evaluation with multiple runs + - Support for various evaluation metrics + - Detailed result reporting + - Integration with evaluation services + +Classes: + AgentEvaluator: Main evaluator class for running agent evaluations +""" + +from __future__ import annotations + +import asyncio +import importlib +import json +import os +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.agents import BaseAgent + +from ._local_eval_service import LocalEvalService +from ._remote_eval_service import CallAgent +from ._remote_eval_service import RemoteEvalService +from . import _utils +from ._eval_callbacks import Callbacks +from ._eval_case import EvalModeTrace +from ._eval_config import EvalConfig +from ._eval_metrics import EvalStatus +from ._eval_pass import pass_at_k as _pass_at_k +from ._eval_pass import pass_hat_k as _pass_hat_k +from ._eval_result import EvalCaseResult +from ._eval_result import EvalSetAggregateResult +from ._eval_result import EvaluateResult +from ._eval_set import EvalSet +from ._eval_sets_manager_base import EvalSetsManager +from ._in_memory_eval_sets_manager import InMemoryEvalSetsManager +from ._local_eval_set_results_manager import LocalEvalSetResultsManager +from ._user_simulator_provider import UserSimulatorProvider + +# Constants for default runs +NUM_RUNS = 1 +# Default app_name when evalset does not set app_name (session/result paths) +DEFAULT_EVAL_APP_NAME = "test_app" + +_RESULT_HANDLER = _utils.EvalResultHandler() + + +class _EvaluationCasesFailed(AssertionError): + """Signal raised by ``_EvalExecuter._run`` when one or more eval cases fail. + + Subclasses :class:`AssertionError` so direct ``AgentEvaluator.evaluate`` + callers (CI pytest gates such as ``examples/optimization/ci_integration``) + keep working unchanged: ``except AssertionError`` and + ``isinstance(exc, AssertionError)`` both still match, and the formatted + message remains the JSON failure summary so pytest JUnit XML output is + byte-for-byte identical to the previous ``assert False, combined``. + + Internal optimizer wrappers (``_optimize_evaluator_call.run_evaluator``) + catch this concrete subclass so unrelated ``AssertionError`` (e.g. numpy + ``assert allclose``) is no longer silently swallowed. Replacing the bare + ``assert`` statement also keeps the failure signal alive under + ``python -O`` where ``assert`` is stripped. + """ + + +@dataclass(frozen=True) +class PassNC: + """(n, c): n = runs, c = runs that all passed (for pass@k / pass^k).""" + + n: int + c: int + + +class _EvalExecuter: + """Returned by get_executer(); await .evaluate() then .get_result().""" + + def __init__( + self, + eval_dataset_file_path_or_dir: str, + *, + agent_module: Optional[str] = None, + call_agent: Optional[CallAgent] = None, + num_runs: int = NUM_RUNS, + agent_name: Optional[str] = None, + print_detailed_results: bool = True, + eval_result_output_dir: Optional[str] = None, + runner: Optional[Runner] = None, + case_parallelism: Optional[int] = None, + case_eval_parallelism: Optional[int] = None, + callbacks: Optional[Callbacks] = None, + eval_metrics_file_path_or_dir: Optional[str] = None, + print_summary_report: bool = True, + ): + self._agent_module = agent_module + self._call_agent = call_agent + self._eval_dataset_file_path_or_dir = eval_dataset_file_path_or_dir + self._num_runs = num_runs + self._agent_name = agent_name + self._print_detailed_results = print_detailed_results + self._print_summary_report = print_summary_report + self._eval_result_output_dir = eval_result_output_dir + self._runner = runner + self._case_parallelism = case_parallelism + self._case_eval_parallelism = case_eval_parallelism + self._callbacks = callbacks + self._eval_metrics_file_path_or_dir = eval_metrics_file_path_or_dir + self._result: Optional[EvaluateResult] = None + self._task: Optional[asyncio.Task] = None + + async def _run(self) -> None: + agent_module = self._agent_module + call_agent = self._call_agent + eval_dataset_file_path_or_dir = self._eval_dataset_file_path_or_dir + num_runs = self._num_runs + agent_name = self._agent_name + print_detailed_results = self._print_detailed_results + print_summary_report = self._print_summary_report + eval_result_output_dir = self._eval_result_output_dir + runner = self._runner + case_parallelism = self._case_parallelism + case_eval_parallelism = self._case_eval_parallelism + callbacks = self._callbacks + eval_metrics_file_path_or_dir = self._eval_metrics_file_path_or_dir + + # Resolve shared config once; None means "fall back to dataset-local test_config.json". + shared_eval_config = AgentEvaluator._resolve_shared_config(eval_metrics_file_path_or_dir) + + test_files = [] + if os.path.isdir(eval_dataset_file_path_or_dir): + for root, _, files in os.walk(eval_dataset_file_path_or_dir): + for file in files: + if file.endswith(".test.json") or file.endswith(".evalset.json"): + test_files.append(os.path.join(root, file)) + else: + test_files = [eval_dataset_file_path_or_dir] + + eval_set_results_manager = None + if eval_result_output_dir: + eval_set_results_manager = LocalEvalSetResultsManager(eval_history_dir=eval_result_output_dir) + + all_failures: list[tuple[str, dict]] = [] + all_details: list[tuple[str, list[str]]] = [] + all_results: list[tuple[str, list[str]]] = [] + results_by_eval_set_id: dict[str, EvalSetAggregateResult] = {} + for test_file in test_files: + if shared_eval_config is not None: + eval_config = shared_eval_config + # When shared config is explicit, honor its num_runs iff user + # set one; we keep the same precedence behavior as the + # dataset-local path (config overrides parameter). + num_runs_for_set = eval_config.num_runs + else: + eval_config = AgentEvaluator.find_config_for_test_file(test_file) + # Config (test_config.json) overrides parameter + config_path = os.path.join(os.path.dirname(test_file), "test_config.json") + num_runs_for_set = (eval_config.num_runs if os.path.exists(config_path) else num_runs) + eval_set = AgentEvaluator._load_eval_set_from_file(test_file, eval_config) + failed_summary, details_lines, result_lines, eval_results_by_eval_id = ( + await AgentEvaluator.evaluate_eval_set( + eval_set, + agent_module=agent_module, + call_agent=call_agent, + eval_config=eval_config, + num_runs=num_runs_for_set, + agent_name=agent_name, + callbacks=callbacks, + print_detailed_results=print_detailed_results, + eval_set_results_manager=eval_set_results_manager, + runner=runner, + case_parallelism=case_parallelism, + case_eval_parallelism=case_eval_parallelism, + )) + if failed_summary is not None: + all_failures.append((eval_set.eval_set_id, failed_summary)) + if print_detailed_results and details_lines: + all_details.append((eval_set.eval_set_id, details_lines)) + if result_lines: + all_results.append((eval_set.eval_set_id, result_lines)) + results_by_eval_set_id[eval_set.eval_set_id] = EvalSetAggregateResult( + eval_results_by_eval_id=eval_results_by_eval_id, + num_runs=num_runs_for_set, + ) + if print_summary_report and (all_details or all_results): + _RESULT_HANDLER.print_evaluation_report( + all_details=all_details, + all_results=all_results, + display_agent_name=agent_name or agent_module + or ("call-agent" if call_agent is not None else "trace-only"), + num_runs=num_runs_for_set, + ) + self._result = EvaluateResult(results_by_eval_set_id=results_by_eval_set_id) + if all_failures: + combined = json.dumps( + [{ + "evalSetId": eid, + "summary": s + } for eid, s in all_failures], + indent=2, + ensure_ascii=False, + ) + raise _EvaluationCasesFailed(combined) + + async def _ensure_run(self) -> None: + if self._task is None: + self._task = asyncio.create_task(self._run()) + await self._task + + async def evaluate(self) -> None: + """Run evaluation. Must be awaited; does not run when executer is created. + + Returns: + None. Use get_result() after await to get EvaluateResult. + """ + await self._ensure_run() + + def get_result(self) -> Optional[EvaluateResult]: + """Return evaluation result. Call only after evaluate() has been awaited. + + Returns: + EvaluateResult, or None if evaluate() has not run or failed before setting result. + """ + return self._result + + +class AgentEvaluator: + """Main interface for evaluating agents. + + This class provides a simple API for running evaluations: + + Example: + ```python + # Create evaluation set + eval_set = EvalSet( + eval_set_id="test_set", + eval_cases=[...], + ) + + # Configure evaluation + eval_config = EvalConfig( + criteria={ + "tool_trajectory_avg_score": 1.0, + "response_match_score": 0.8, + } + ) + + # Run evaluation + await AgentEvaluator.evaluate_eval_set( + eval_set, + agent_module="my_pkg.my_agent", + eval_config=eval_config, + ) + ``` + """ + + @staticmethod + def find_config_for_test_file(test_file: str) -> EvalConfig: + """Find the test_config.json file in the same folder as the test file. + + Args: + test_file: Path to the test file + + Returns: + EvalConfig loaded from test_config.json or default config + """ + test_folder = os.path.dirname(test_file) + config_path = os.path.join(test_folder, "test_config.json") + return AgentEvaluator._load_config_from_file(config_path) + + @staticmethod + def _is_trace_only(eval_set: EvalSet) -> bool: + """Return True iff every case in the EvalSet has eval_mode == 'trace'. + + Empty eval_cases list returns True by ``all()`` semantics; callers + that require at least one case should validate separately. + """ + return all(case.eval_mode == EvalModeTrace for case in eval_set.eval_cases) + + @staticmethod + async def evaluate( + eval_dataset_file_path_or_dir: str, + *, + agent_module: Optional[str] = None, + call_agent: Optional[CallAgent] = None, + num_runs: int = NUM_RUNS, + agent_name: Optional[str] = None, + print_detailed_results: bool = True, + eval_result_output_dir: Optional[str] = None, + runner: Optional[Runner] = None, + case_parallelism: Optional[int] = None, + case_eval_parallelism: Optional[int] = None, + callbacks: Optional[Callbacks] = None, + eval_metrics_file_path_or_dir: Optional[str] = None, + ) -> None: + """Run evaluation; no result returned. Use get_executer() if you need the result. + + Args: + eval_dataset_file_path_or_dir: Path to eval dataset file or directory + (recursively .test.json / .evalset.json). Positional-only usage + recommended. + agent_module: Python module path containing the agent (look for + 'root_agent' or 'get_agent_async'). Optional when every case in + every discovered dataset uses ``eval_mode='trace'``. + num_runs: Number of runs per eval set. + agent_name: Display name of the agent. + print_detailed_results: Whether to print per-case details. + eval_result_output_dir: Optional dir for .evalset_result.json output. + runner: Optional Runner instance. + case_parallelism: Max concurrent cases for inference; None uses default. + case_eval_parallelism: Max concurrent cases for evaluation (scoring); + None uses default. + callbacks: Optional lifecycle callbacks. + eval_metrics_file_path_or_dir: Optional explicit path to a shared + evaluation config JSON (file) or directory containing a single + config JSON. When provided, overrides the dataset-local + ``test_config.json`` convention for ALL discovered datasets. + """ + executer = AgentEvaluator.get_executer( + eval_dataset_file_path_or_dir, + agent_module=agent_module, + call_agent=call_agent, + num_runs=num_runs, + agent_name=agent_name, + print_detailed_results=print_detailed_results, + eval_result_output_dir=eval_result_output_dir, + runner=runner, + case_parallelism=case_parallelism, + case_eval_parallelism=case_eval_parallelism, + callbacks=callbacks, + eval_metrics_file_path_or_dir=eval_metrics_file_path_or_dir, + ) + await executer.evaluate() + + @staticmethod + def get_executer( + eval_dataset_file_path_or_dir: str, + *, + agent_module: Optional[str] = None, + call_agent: Optional[CallAgent] = None, + num_runs: int = NUM_RUNS, + agent_name: Optional[str] = None, + print_detailed_results: bool = True, + eval_result_output_dir: Optional[str] = None, + runner: Optional[Runner] = None, + case_parallelism: Optional[int] = None, + case_eval_parallelism: Optional[int] = None, + callbacks: Optional[Callbacks] = None, + eval_metrics_file_path_or_dir: Optional[str] = None, + print_summary_report: bool = True, + ) -> _EvalExecuter: + """Return an executer (does not run). Await executer.evaluate() then executer.get_result() for result. + + Args: + eval_dataset_file_path_or_dir: Path to eval dataset file or directory + (recursively .test.json / .evalset.json). Positional-only usage + recommended. + agent_module: Python module path containing the agent (look for + 'root_agent' or 'get_agent_async'). Optional when every case in + every discovered dataset uses ``eval_mode='trace'``. + num_runs: Number of runs per eval set. + agent_name: Display name of the agent. + print_detailed_results: Whether to print per-case details. + eval_result_output_dir: Optional dir for .evalset_result.json output. + runner: Optional Runner instance. + case_parallelism: Max concurrent cases for inference; None uses default. + case_eval_parallelism: Max concurrent cases for evaluation (scoring); + None uses default. + callbacks: Optional lifecycle callbacks. + eval_metrics_file_path_or_dir: Optional explicit path to a shared + evaluation config JSON (file) or directory containing a single + config JSON. When provided, overrides the dataset-local + ``test_config.json`` convention for ALL discovered datasets. + print_summary_report: When False, suppress the Execution Details and + Evaluation Result tables normally printed at the end of a run. + The result is still computed and returned by ``get_result()``. + Defaults to True for direct callers; tools that drive the + evaluator inside a larger workflow (e.g. ``AgentOptimizer``) + pass False to keep their own output unmixed. + + Returns: + _EvalExecuter: Await .evaluate() to run, then .get_result() for EvaluateResult. + """ + return _EvalExecuter( + eval_dataset_file_path_or_dir, + agent_module=agent_module, + call_agent=call_agent, + num_runs=num_runs, + agent_name=agent_name, + print_detailed_results=print_detailed_results, + eval_result_output_dir=eval_result_output_dir, + runner=runner, + case_parallelism=case_parallelism, + case_eval_parallelism=case_eval_parallelism, + callbacks=callbacks, + eval_metrics_file_path_or_dir=eval_metrics_file_path_or_dir, + print_summary_report=print_summary_report, + ) + + @staticmethod + def _nc_from_set( + eval_results_by_eval_id: dict[str, list[EvalCaseResult]], + num_runs: int, + ) -> tuple[int, int]: + """From one eval set's results, compute (n, c). n = num_runs; c = runs where every case passed.""" + if num_runs <= 0: + raise ValueError("num_runs must be > 0") + n = num_runs + c = 0 + for run_i in range(num_runs): + run_passed = True + for eval_id, results in eval_results_by_eval_id.items(): + if len(results) <= run_i: + raise ValueError(f"eval_id {eval_id!r} has only {len(results)} runs, expected at least {num_runs}") + if results[run_i].final_eval_status != EvalStatus.PASSED: + run_passed = False + break + if run_passed: + c += 1 + return (n, c) + + @staticmethod + def parse_pass_nc(result: EvaluateResult) -> dict[str, PassNC]: + """From EvaluateResult, get PassNC (n, c) per eval set (key = eval set id).""" + out: dict[str, PassNC] = {} + for eid, set_result in result.results_by_eval_set_id.items(): + n, c = AgentEvaluator._nc_from_set(set_result.eval_results_by_eval_id, set_result.num_runs) + out[eid] = PassNC(n=n, c=c) + return out + + @staticmethod + def pass_at_k(n: int, c: int, k: int) -> float: + """Probability that at least one of k attempts succeeds. Delegates to _eval_pass.pass_at_k.""" + return _pass_at_k(n, c, k) + + @staticmethod + def pass_hat_k(n: int, c: int, k: int) -> float: + """Probability that all k consecutive runs succeed. Delegates to _eval_pass.pass_hat_k.""" + return _pass_hat_k(n, c, k) + + @staticmethod + async def evaluate_eval_set( + eval_set: EvalSet, + *, + agent_module: Optional[str] = None, + call_agent: Optional[CallAgent] = None, + eval_config: Optional[EvalConfig] = None, + num_runs: int = NUM_RUNS, + agent_name: Optional[str] = None, + print_detailed_results: bool = True, + eval_set_results_manager: Optional[Any] = None, + runner: Optional[Runner] = None, + case_parallelism: Optional[int] = None, + case_eval_parallelism: Optional[int] = None, + callbacks: Optional[Callbacks] = None, + ) -> tuple[Optional[dict], list[str], list[str], dict[str, list[EvalCaseResult]]]: + """Evaluates an agent using the given EvalSet. + + Args: + eval_set: The eval set. (Positional-only usage recommended.) + agent_module: The path to python module that contains the definition of + the agent. There is convention in place here, where the code is going to + look for 'root_agent' or `get_agent_async` in the loaded module. + Optional when every case in ``eval_set`` has ``eval_mode == 'trace'`` + (pre-recorded conversation used as inference result, no agent run). + Required otherwise; a ``ValueError`` is raised listing non-trace + case ids. + eval_config: The evaluation config. + num_runs: Number of times all entries in the eval dataset should be + assessed. + agent_name: The name of the agent, if trying to evaluate something other + than root agent. If left empty or none, then root agent is evaluated. + print_detailed_results: When True, include Execution Details in + details_lines; Evaluation Result summary is always in result_lines. + eval_set_results_manager: Optional manager for saving eval set results + (e.g. LocalEvalSetResultsManager). + runner: Optional user-provided Runner; when set, use it as-is and only + update its session when session_input exists and values are set in case. + case_parallelism: Max concurrent case inferences. If None, use + InferenceConfig default (4). + case_eval_parallelism: Max concurrent cases for evaluation (scoring). If None, + use EvaluateConfig default (4). + callbacks: Optional lifecycle callbacks (before/after inference and evaluate). + + Returns: + Tuple of (failed_summary or None, details_lines, result_lines, eval_results_by_eval_id). + When print_detailed_results is False, details_lines is []. + """ + if eval_config is None: + raise ValueError("`eval_config` is required.") + + if call_agent is not None and agent_module is not None: + raise ValueError("call_agent is mutually exclusive with agent_module.") + if call_agent is not None and runner is not None: + raise ValueError("call_agent is mutually exclusive with runner.") + + trace_only = AgentEvaluator._is_trace_only(eval_set) + if call_agent is None and agent_module is None and not trace_only: + non_trace_ids = [case.eval_id for case in eval_set.eval_cases if case.eval_mode != EvalModeTrace] + raise ValueError("`agent_module` is required unless every case in eval_set uses " + "eval_mode='trace'. Non-trace case ids: " + f"{non_trace_ids}") + + agent_for_eval: Optional[BaseAgent] = None + if agent_module is not None and call_agent is None: + agent_for_eval = await AgentEvaluator._get_agent_for_eval(module_name=agent_module, agent_name=agent_name) + eval_metrics = eval_config.get_eval_metrics() + + user_simulator_provider = UserSimulatorProvider(user_simulator_config=eval_config.user_simulator_config) + + # Step 1: Perform evals, basically inferencing and evaluation of metrics + eval_results_by_eval_id = await AgentEvaluator._get_eval_results_by_eval_id( + agent_for_eval=agent_for_eval, + eval_set=eval_set, + eval_set_results_manager=eval_set_results_manager, + eval_metrics=eval_metrics, + num_runs=num_runs, + user_simulator_provider=user_simulator_provider, + runner=runner, + case_parallelism=case_parallelism, + case_eval_parallelism=case_eval_parallelism, + callbacks=callbacks, + call_agent=call_agent, + ) + + # Step 2: Post-process the results + failures: list[str] = [] + display_agent_name = agent_name or agent_module or ("call-agent" if call_agent is not None else "trace-only") + details_lines: list[str] = [] + result_lines: list[str] = [] + + for eval_id, eval_results_per_eval_id in sorted(eval_results_by_eval_id.items()): + eval_metric_results = (AgentEvaluator._get_eval_metric_results_with_invocation(eval_results_per_eval_id)) + sink = details_lines if print_detailed_results else None + failures_per_eval_case = _RESULT_HANDLER.process_metrics_and_get_failures( + eval_metric_results=eval_metric_results, + print_detailed_results=print_detailed_results, + agent_module=display_agent_name, + eval_id=eval_id, + eval_set_id=eval_set.eval_set_id, + details_sink=sink, + ) + failures.extend(failures_per_eval_case) + + summary = _RESULT_HANDLER.build_summary( + eval_set=eval_set, + eval_results_by_eval_id=eval_results_by_eval_id, + agent_name=display_agent_name, + num_runs=num_runs, + ) + # Evaluation Result summary is always built and printed + result_lines = _RESULT_HANDLER.build_evaluation_result_lines( + summary, + include_completed_line=False, + include_agent_runs=False, + ) + failed_summary = (_RESULT_HANDLER.summary_to_export_dict(summary) if failures else None) + return (failed_summary, details_lines, result_lines, eval_results_by_eval_id) + + @staticmethod + async def _get_agent_for_eval(module_name: str, agent_name: Optional[str] = None) -> BaseAgent: + """Get agent for evaluation from a Python module. + + Supports both ADK Python strict convention and TRPC flexible loading: + 1. ADK convention: module.agent.root_agent or module.agent.get_agent_async() + 2. TRPC flexible: module.root_agent, module.get_agent_async(), module.get_agent() + + Args: + module_name: Module path (e.g. "my_package.my_agent") + agent_name: Optional name of specific agent to load + + Returns: + The loaded agent instance + + Raises: + ValueError: If agent cannot be found in module + """ + module_path = f"{module_name}" + agent_module_obj = importlib.import_module(module_path) + + root_agent = None + + # Try ADK convention first: module.agent.* or module name ends with .agent + if hasattr(agent_module_obj, "agent") or module_name.endswith(".agent"): + agent_module_with_agent = (agent_module_obj.agent + if hasattr(agent_module_obj, "agent") else agent_module_obj) + + if hasattr(agent_module_with_agent, "root_agent"): + root_agent = agent_module_with_agent.root_agent + elif hasattr(agent_module_with_agent, "get_agent_async"): + root_agent, _ = await agent_module_with_agent.get_agent_async() + + # Fallback to TRPC flexible convention + if root_agent is None: + if hasattr(agent_module_obj, "root_agent"): + root_agent = agent_module_obj.root_agent + elif hasattr(agent_module_obj, "get_agent_async"): + root_agent, _ = await agent_module_obj.get_agent_async() + elif hasattr(agent_module_obj, "get_agent"): + root_agent = agent_module_obj.get_agent() + + if root_agent is None: + raise ValueError(f"Module '{module_name}' does not have a 'root_agent', 'agent', " + f"'get_agent_async', or 'get_agent' attribute following either " + f"ADK or TRPC conventions.") + + agent_for_eval = root_agent + if agent_name: + agent_for_eval = root_agent.find_agent(agent_name) + assert agent_for_eval, f"Sub-Agent `{agent_name}` not found." + + return agent_for_eval + + @staticmethod + def _load_eval_set_from_file( + eval_set_file: str, + eval_config: EvalConfig, + ) -> EvalSet: + """Load evaluation set from JSON file. + + Supports ADK-style syntax: "file.json:eval_case_id" to select a single case. + Session input is specified per case in the EvalSet file (session_input). + + Args: + eval_set_file: Path to the JSON file, optionally with ":eval_case_id" suffix + eval_config: Evaluation configuration (for compatibility with ADK) + + Returns: + Loaded EvalSet instance + + Raises: + FileNotFoundError: If file doesn't exist + ValueError: If file format is invalid or eval case not found + """ + # Check if file_path contains a case selector (ADK style: "file.json:case_id") + selected_case_id = None + actual_file_path = eval_set_file + + if ":" in eval_set_file: + parts = eval_set_file.split(":", 1) + actual_file_path = parts[0] + selected_case_id = parts[1] + + if not os.path.exists(actual_file_path): + raise FileNotFoundError(f"Eval set file not found: {actual_file_path}") + + with open(actual_file_path, "r", encoding="utf-8") as f: + content = f.read() + + try: + eval_set = EvalSet.model_validate_json(content) + + # If a specific case was selected, filter the eval set + if selected_case_id: + matching_cases = [case for case in eval_set.eval_cases if case.eval_id == selected_case_id] + + if not matching_cases: + raise ValueError(f"Eval case '{selected_case_id}' not found in {actual_file_path}. " + f"Available cases: {[c.eval_id for c in eval_set.eval_cases]}") + + # Create a new eval set with only the selected case + eval_set = EvalSet( + eval_set_id=f"{eval_set.eval_set_id}_{selected_case_id}", + name=f"{eval_set.name} - {selected_case_id}", + description=eval_set.description, + eval_cases=matching_cases, + ) + + return eval_set + except Exception as ex: + raise ValueError(f"Failed to load eval set from {actual_file_path}: {ex}") + + @staticmethod + def _load_config_from_file(file_path: Optional[str]) -> EvalConfig: + """Load evaluation config from JSON file. + + Args: + file_path: Path to the config JSON file (optional) + + Returns: + Loaded EvalConfig instance or default config + """ + if file_path is None or not os.path.exists(file_path): + # Return default config + return EvalConfig(criteria={ + "tool_trajectory_avg_score": 1.0, + "response_match_score": 0.7, + }) + + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + try: + eval_config = EvalConfig.model_validate_json(content) + return eval_config + except Exception as ex: + raise ValueError(f"Failed to load config from {file_path}: {ex}") + + @staticmethod + def _load_config_from_file_strict(file_path: str) -> EvalConfig: + """Load EvalConfig from JSON file; raise if missing (no default fallback). + + Unlike ``_load_config_from_file`` which silently returns a default config + when the file is absent, this strict variant is intended for explicit + user-supplied paths (e.g. ``eval_metrics_file_path_or_dir``) where a + missing file is a programmer error, not a signal to use defaults. + + Args: + file_path: Path to the config JSON file. Must exist. + + Returns: + EvalConfig instance loaded from ``file_path``. + + Raises: + FileNotFoundError: If ``file_path`` does not exist. + ValueError: If the file cannot be parsed as EvalConfig JSON. + """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"Eval metrics/config file not found: {file_path}") + return AgentEvaluator._load_config_from_file(file_path) + + @staticmethod + def _resolve_shared_config(eval_metrics_file_path_or_dir: Optional[str], ) -> Optional[EvalConfig]: + """Resolve a user-provided metrics/config path into a single EvalConfig. + + Resolution rules: + - ``None`` -> returns ``None`` (no shared config; callers + fall back to per-dataset ``test_config.json`` convention). + - Path is a regular file -> load that file strictly. + - Path is a directory: + - Exactly one ``*.json`` in the directory (non-recursive) -> load it. + - Zero ``*.json`` -> ``FileNotFoundError``. + - Two or more -> ``ValueError`` (ambiguous). + - Path does not exist -> ``FileNotFoundError``. + + Args: + eval_metrics_file_path_or_dir: User-supplied path or None. + + Returns: + EvalConfig when a shared config was resolved, else None. + """ + if eval_metrics_file_path_or_dir is None: + return None + + path = eval_metrics_file_path_or_dir + if not os.path.exists(path): + raise FileNotFoundError(f"eval_metrics_file_path_or_dir does not exist: {path}") + + if os.path.isfile(path): + return AgentEvaluator._load_config_from_file_strict(path) + + # Directory case: non-recursive lookup for *.json + json_files = sorted( + os.path.join(path, entry) for entry in os.listdir(path) + if entry.endswith(".json") and os.path.isfile(os.path.join(path, entry))) + if not json_files: + raise FileNotFoundError(f"No *.json config file found in directory: {path}") + if len(json_files) > 1: + raise ValueError("eval_metrics_file_path_or_dir directory contains multiple " + f"*.json files; expected exactly one. Found: {json_files}") + return AgentEvaluator._load_config_from_file_strict(json_files[0]) + + @staticmethod + def _get_eval_sets_manager(app_name: str, eval_set: EvalSet) -> EvalSetsManager: + """Create and populate an in-memory eval sets manager. + + Args: + app_name: Application name + eval_set: The eval set to add + + Returns: + Populated EvalSetsManager + """ + eval_sets_manager = InMemoryEvalSetsManager() + + eval_sets_manager.create_eval_set(app_name=app_name, eval_set_id=eval_set.eval_set_id) + for eval_case in eval_set.eval_cases: + eval_sets_manager.add_eval_case( + app_name=app_name, + eval_set_id=eval_set.eval_set_id, + eval_case=eval_case, + ) + + return eval_sets_manager + + @staticmethod + async def _get_eval_results_by_eval_id( + agent_for_eval: Optional[BaseAgent], + eval_set: EvalSet, + eval_metrics: list, + num_runs: int, + user_simulator_provider, + call_agent: Optional[CallAgent] = None, + eval_set_results_manager: Optional[Any] = None, + runner: Optional[Runner] = None, + case_parallelism: Optional[int] = None, + case_eval_parallelism: Optional[int] = None, + callbacks: Optional[Callbacks] = None, + ) -> dict[str, list[EvalCaseResult]]: + """Returns EvalCaseResults grouped by eval case id. + + The grouping happens because of the "num_runs" argument, where for any value + greater than 1, we would have generated inferences num_runs times and so + by extension we would have evaluated metrics on each of those inferences. + + Args: + agent_for_eval: The agent to evaluate + eval_set: The eval set + eval_metrics: List of metrics to evaluate + num_runs: Number of times to run each eval case + user_simulator_provider: Provider for user simulators + eval_set_results_manager: Optional manager for saving eval set results + runner: Optional user-provided Runner; when set, use it as-is and only + update its session when session_input exists and values are set in case. + case_parallelism: Max concurrent case inferences. If None, use + InferenceConfig default (4). + case_eval_parallelism: Max concurrent cases for evaluation (scoring). If None, + use EvaluateConfig default (4). + callbacks: Optional lifecycle callbacks (before/after inference and evaluate). + + Returns: + Dictionary mapping eval_id to list of EvalCaseResult + """ + from ._eval_service_base import ( + InferenceRequest, + InferenceConfig, + EvaluateRequest, + EvaluateConfig, + ) + + # app_name: evalset.app_name or configured default (case session_input.app_name overrides per case) + request_app_name = eval_set.app_name or DEFAULT_EVAL_APP_NAME + + eval_sets_manager = AgentEvaluator._get_eval_sets_manager(app_name=request_app_name, eval_set=eval_set) + if call_agent is not None: + eval_service = RemoteEvalService( + call_agent=call_agent, + eval_sets_manager=eval_sets_manager, + eval_set_results_manager=eval_set_results_manager, + callbacks=callbacks, + ) + else: + eval_service = LocalEvalService( + root_agent=agent_for_eval, + eval_sets_manager=eval_sets_manager, + user_simulator_provider=user_simulator_provider, + eval_set_results_manager=eval_set_results_manager, + runner=runner, + callbacks=callbacks, + ) + + inference_config = (InferenceConfig( + parallelism=case_parallelism) if case_parallelism is not None else InferenceConfig()) + inference_requests = [ + InferenceRequest( + app_name=request_app_name, + eval_set_id=eval_set.eval_set_id, + inference_config=inference_config, + ) + ] * num_runs # Repeat inference request num_runs times. + + # Generate inferences + inference_results = [] + for run_id, inference_request in enumerate(inference_requests, start=1): + async for inference_result in eval_service.perform_inference(inference_request=inference_request): + inference_result.run_id = run_id + inference_results.append(inference_result) + + # Evaluate metrics + # As we perform more than one run for an eval case, we collect eval results + # by eval id. + eval_results_by_eval_id: dict[str, list[EvalCaseResult]] = {} + evaluate_config = (EvaluateConfig(eval_metrics=eval_metrics, parallelism=case_eval_parallelism) + if case_eval_parallelism is not None else EvaluateConfig(eval_metrics=eval_metrics)) + evaluate_request = EvaluateRequest( + inference_results=inference_results, + evaluate_config=evaluate_config, + ) + + async for eval_result in eval_service.evaluate(evaluate_request=evaluate_request): + eval_id = eval_result.eval_id + if eval_id not in eval_results_by_eval_id: + eval_results_by_eval_id[eval_id] = [] + + eval_results_by_eval_id[eval_id].append(eval_result) + + return eval_results_by_eval_id + + # yapf: disable + @staticmethod + def _get_eval_metric_results_with_invocation( + eval_results_per_eval_id: list[EvalCaseResult], + ) -> dict[str, list[_utils.MetricRunRecord]]: + # yapf: enable + """Returns MetricRunRecord grouped by metric. + + EvalCaseResult contain results for each metric per invocation. + + This method flips it around and returns a structure that groups metric + results per invocation by eval metric. + + This is a convenience function. + + Args: + eval_results_per_eval_id: List of eval case results for the same eval_id + + Returns: + Dictionary mapping metric names to lists of results with invocations + """ + eval_metric_results: dict[str, list[_utils.MetricRunRecord]] = {} + + # Go over the EvalCaseResult one by one + for eval_case_result in eval_results_per_eval_id: + # For the given eval_case_result, we go over metric results for each + # invocation + for eval_metrics_per_invocation in eval_case_result.eval_metric_result_per_invocation: + # Go over each eval_metric_result for an invocation + for eval_metric_result in eval_metrics_per_invocation.eval_metric_results: + metric_name = eval_metric_result.metric_name + if metric_name not in eval_metric_results: + eval_metric_results[metric_name] = [] + + actual_invocation = eval_metrics_per_invocation.actual_invocation + expected_invocation = eval_metrics_per_invocation.expected_invocation + + eval_metric_results[metric_name].append( + _utils.MetricRunRecord( + actual_invocation=actual_invocation, + expected_invocation=expected_invocation, + eval_metric_result=eval_metric_result, + )) + return eval_metric_results diff --git a/trpc_agent_sdk/evaluation/_agent_optimizer.py b/trpc_agent_sdk/evaluation/_agent_optimizer.py new file mode 100644 index 000000000..16510e758 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_agent_optimizer.py @@ -0,0 +1,614 @@ +# 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. +"""AgentOptimizer: business-facing entry point for prompt optimization. + +Mirrors :class:`AgentEvaluator`: business code calls +``AgentOptimizer.optimize(...)`` and the facade dispatches to the +algorithm registered under ``config.optimize.algorithm.name`` (looked +up in :data:`OPTIMIZER_REGISTRY`). Switching algorithms is a +single-field config change. +""" + +from __future__ import annotations + +import inspect +import logging +import os +import signal +import sys +import threading +import warnings +from datetime import datetime +from datetime import timezone +from pathlib import Path +from typing import Any +from typing import Optional +from typing import Sequence + +from ._eval_callbacks import Callbacks +from ._optimize_config import OptimizeConfigFile +from ._optimize_config import load_optimize_config +from ._optimize_registry import OPTIMIZER_REGISTRY +from ._optimize_reporter import RunHeader +from ._optimize_reporter import create_reporter +from ._optimize_result import OptimizeResult +from ._remote_eval_service import CallAgent +from ._target_prompt import TargetPrompt + +# Metrics incompatible with call_agent (black-box) mode because their +# evaluators need data RemoteEvalService doesn't capture: +# - ``tool_trajectory_avg_score``: per-step tool call traces. +# - ``llm_rubric_knowledge_recall``: tool responses from +# ``Invocation.intermediate_data`` (RemoteEvalService leaves it None, +# so the judge would always see "No knowledge search results were +# found." for every case). +_DISALLOWED_METRICS_IN_CALL_AGENT_MODE = frozenset({ + "tool_trajectory_avg_score", + "llm_rubric_knowledge_recall", +}) + +_PROMPT_FILE_LOGGER = logging.getLogger("trpc_agent_sdk.optimizer") + + +def _atomic_write_text(path: str, content: str) -> None: + """Atomically replace ``path`` with ``content`` (UTF-8). + + Writes to a sibling ``.tmp`` then ``os.replace`` to swap into + place — POSIX guarantees rename is atomic, so a process kill or + power loss between the write and the rename leaves ``path`` either + pristine (pre-call content, or missing if it did not exist) or + fully updated, never half-written. Mirrors + :meth:`TargetPrompt._atomic_write_path` so artifact persistence + enjoys the same crash safety as source rollback. + """ + tmp = path + ".tmp" + Path(tmp).write_text(content, encoding="utf-8") + os.replace(tmp, path) + + +class _mask_sigint: + """Context manager that masks SIGINT for the duration of the block. + + Used by :meth:`AgentOptimizer._persist_artifacts` so a panicked second + Ctrl+C during teardown cannot interrupt artifact writes between + ``os.replace`` boundaries. Restores the previous handler on exit even + if the block raises. On platforms / threads where ``signal.signal`` + is unavailable (Windows, non-main thread) the context degrades to a + no-op rather than crashing — the underlying ``_atomic_write_text`` is + still crash-safe; only the second-Ctrl+C-during-finally race + protection is foregone. + """ + + def __init__(self) -> None: + self._previous = None + self._installed = False + + def __enter__(self) -> "_mask_sigint": + # signal.signal() only works in the main thread of the main interpreter. + if threading.current_thread() is not threading.main_thread(): + return self + try: + self._previous = signal.signal(signal.SIGINT, signal.SIG_IGN) + self._installed = True + except (ValueError, OSError): # pragma: no cover - platform fallback + # ValueError: not main thread on some platforms; OSError: signal + # not supported (rare embedded interpreters). Either way, leave + # SIGINT as-is; persistence is still best-effort. + self._installed = False + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if not self._installed: + return + try: + signal.signal(signal.SIGINT, self._previous) + except (ValueError, OSError): # pragma: no cover - platform fallback + pass + + +class AgentOptimizer: + """Business-facing entry point dispatching to the registered algorithm. + + Business code passes a config file path; the facade reads + validates + it, looks up the algorithm class from + :data:`OPTIMIZER_REGISTRY` by ``config.optimize.algorithm.name``, + instantiates it, and runs the loop. + + Example: + target = TargetPrompt().add_path("system_prompt", "prompts/system.md") + result = await AgentOptimizer.optimize( + config_path="optimizer.json", + call_agent=my_call_agent, + target_prompt=target, + train_dataset_path="data/train.evalset.json", + validation_dataset_path="data/val.evalset.json", + output_dir="runs/2026-05-17T16-30-00", + ) + """ + + @classmethod + async def optimize( + cls, + *, + config_path: str, + call_agent: CallAgent, + target_prompt: TargetPrompt, + train_dataset_path: str, + validation_dataset_path: str, + output_dir: str, + callbacks: Optional[Callbacks] = None, + update_source: bool = False, + verbose: int = 1, + extra_stop_callbacks: Optional[Sequence[Any]] = None, + extra_gepa_callbacks: Optional[Sequence[Any]] = None, + ) -> OptimizeResult: + """Load the config file at ``config_path`` and run the selected algorithm. + + Args: + config_path: Path to the optimizer JSON config file. + call_agent: Async callable mapping a user query to an agent response. + target_prompt: Registry of prompt fields to optimize. + train_dataset_path: Path to the training eval set file. + validation_dataset_path: Path to the validation eval set file (must + differ from ``train_dataset_path``). + output_dir: Required artifact directory. The facade creates it when + missing and persists ``result.json``, ``summary.txt``, + ``rounds/`` records, ``baseline_prompts/`` and ``best_prompts/`` + directories, a ``config.snapshot.json`` copy of the input + config, and a ``run.log`` summary line. + callbacks: Optional evaluator lifecycle callbacks. + update_source: When True, persist the best candidate back to + every registered TargetPrompt field after a SUCCEEDED + run; when False (default), source files keep their + baseline content. ``OptimizeResult.best_prompts`` always + carries the best text regardless, so callers can review + the proposal before deciding to write back. + verbose: Reporter verbosity. ``0`` suppresses terminal + output (artifact persistence still happens). ``1`` + (default): Rich panel header + per-round line + closing + summary, falling back to ASCII when ``rich`` is missing. + ``2`` adds gepa-internal log forwarding on the + ``trpc_agent_sdk.optimizer.gepa`` logger. + extra_stop_callbacks: Runtime-only stoppers appended after + gepa-native stoppers. Useful for SLO monitors / kill + switches. Plain callables surface as + ``stop_reason="completed"``; wrap in + ``_LabeledStopper`` (or expose a ``.label`` attribute + matching :data:`StopReason`) for a stable classification. + extra_gepa_callbacks: Runtime-only gepa event callbacks + appended after the framework's built-in + ``_AgentGEPACallback`` (e.g. forwarding events to a + dashboard). Each entry should implement the + ``gepa.core.callback.GEPACallback`` protocol; gepa + silently ignores callbacks missing a method it invokes. + + Raises: + FileNotFoundError: if ``config_path`` does not exist. + pydantic.ValidationError: if the config violates schema constraints. + ValueError: if ``optimize`` section is missing; if the requested + ``algorithm.name`` is not registered (message lists every + algorithm currently in ``OPTIMIZER_REGISTRY.list_registered()``); + if ``target_prompt`` has no registered fields; if a metric + requiring session traces is configured under call_agent mode; or + if ``train_dataset_path`` and ``validation_dataset_path`` resolve + to the same file (train-test leakage guard). + TypeError: if ``call_agent`` is not an ``async`` callable. + """ + cls._precheck_algorithm_name(config_path) + config = load_optimize_config(config_path) + cls._validate_inputs( + config=config, + call_agent=call_agent, + target_prompt=target_prompt, + train_dataset_path=train_dataset_path, + validation_dataset_path=validation_dataset_path, + output_dir=output_dir, + ) + os.makedirs(output_dir, exist_ok=True) + + algorithm_name = config.optimize.algorithm.name + algorithm_cls = OPTIMIZER_REGISTRY.get(algorithm_name) + optimizer = algorithm_cls( + config=config, + call_agent=call_agent, + target_prompt=target_prompt, + train_dataset_path=train_dataset_path, + validation_dataset_path=validation_dataset_path, + callbacks=callbacks, + output_dir=output_dir, + extra_stop_callbacks=extra_stop_callbacks, + extra_gepa_callbacks=extra_gepa_callbacks, + ) + + reporter = create_reporter(verbose=verbose, stream=sys.stdout) + baseline_snapshot = await target_prompt.read_all() + header = cls._build_run_header( + algorithm=algorithm_name, + target_prompt=target_prompt, + config=config, + train_dataset_path=train_dataset_path, + validation_dataset_path=validation_dataset_path, + output_dir=output_dir, + ) + cls._safe_reporter_call(reporter.run_started, header) + + result: Optional[OptimizeResult] = None + # ``cleanup_done`` gates whether the ``finally`` block must restore + # baseline. It flips to True after EITHER (a) write_all(best) succeeded + # (so sources already hold the desired content and no restore is + # needed) OR (b) the ``except`` branch successfully wrote baseline back + # as part of its rollback. This single sentinel guarantees baseline + # write_all is invoked at most once per optimize() — important for + # callback-backed fields whose write_fn may be non-idempotent (version + # counters, audit log entries). + cleanup_done = False + run_error: Optional[BaseException] = None + try: + try: + result = await optimizer.run(reporter=reporter) + except BaseException as ex: + run_error = ex + raise + + if update_source and result.status == "SUCCEEDED": + # write_all is atomic for path-backed sources (tmp + + # os.replace, rollback on partial failure). If it raises, + # sources may sit at an intermediate candidate from the + # last in-run evaluation — restore baseline explicitly + # then re-raise so the caller sees the write failure. + try: + await target_prompt.write_all(result.best_prompts) + cleanup_done = True + except Exception: + try: + await target_prompt.write_all(baseline_snapshot) + cleanup_done = True + except Exception: # pragma: no cover - defensive guard + pass + raise + finally: + if not cleanup_done: + # Best-effort restore: never mask the underlying run/write error. + try: + await target_prompt.write_all(baseline_snapshot) + except Exception: # pragma: no cover - defensive guard + pass + + cls._persist_artifacts( + result=result, + baseline_snapshot=baseline_snapshot, + output_dir=output_dir, + config_path=config_path, + run_error=run_error, + update_source=update_source, + ) + cls._emit_reporter_finish( + reporter=reporter, + result=result, + baseline_snapshot=baseline_snapshot, + output_dir=output_dir, + update_source=update_source, + run_error=run_error, + ) + return result + + @staticmethod + def _build_run_header( + *, + algorithm: str, + target_prompt: TargetPrompt, + config: OptimizeConfigFile, + train_dataset_path: str, + validation_dataset_path: str, + output_dir: str, + ) -> RunHeader: + """Collect the static run context surfaced in the terminal header. + + Train / val sizes are read from each EvalSet on disk so the header + reflects the actual material the algorithm will evaluate, including + edge cases where one of the sets is empty. + """ + from ._eval_set import EvalSet + from pathlib import Path + + def _count_cases(path: str) -> int: + try: + return len(EvalSet.model_validate_json(Path(path).read_text(encoding="utf-8")).eval_cases) + except Exception: + return 0 + + target_fields: list[tuple[str, str]] = [] + for name in target_prompt.names(): + target_fields.append((name, target_prompt.describe_source(name))) + + metric_names = [metric.metric_name for metric in config.evaluate.get_eval_metrics()] + budget_total = getattr(config.optimize.algorithm, "max_metric_calls", None) + return RunHeader( + algorithm=algorithm, + target_fields=target_fields, + train_size=_count_cases(train_dataset_path), + val_size=_count_cases(validation_dataset_path), + metric_names=metric_names, + output_dir=output_dir, + budget_total=budget_total, + ) + + @staticmethod + def _safe_reporter_call(fn, *args, **kwargs) -> None: + """Invoke a reporter method, swallowing render errors.""" + try: + fn(*args, **kwargs) + except Exception: # pragma: no cover - reporter must never break the loop + _PROMPT_FILE_LOGGER.warning("reporter event failed", exc_info=True) + + @classmethod + def _emit_reporter_finish( + cls, + *, + reporter, + result: Optional[OptimizeResult], + baseline_snapshot: dict[str, str], + output_dir: str, + update_source: bool, + run_error: Optional[BaseException], + ) -> None: + if result is not None: + cls._safe_reporter_call( + reporter.run_finished, + result, + output_dir=output_dir, + update_source=update_source, + ) + return + message = (str(run_error) if run_error is not None else "optimization failed") + cls._safe_reporter_call( + reporter.run_failed, + baseline_prompts=dict(baseline_snapshot), + output_dir=output_dir, + error_message=message, + ) + + @classmethod + def _persist_artifacts( + cls, + *, + result: Optional[OptimizeResult], + baseline_snapshot: dict[str, str], + output_dir: str, + config_path: str, + run_error: Optional[BaseException], + update_source: bool, + ) -> None: + """Write run artifacts under ``output_dir``. + + Layout: + - ``result.json`` Full OptimizeResult JSON. + - ``summary.txt`` Human-readable summary. + - ``rounds/round_.json`` One file per RoundRecord. + - ``baseline_prompts/.md`` Pre-run snapshot of every + TargetPrompt field + (regardless of update_source). + - ``best_prompts/.md`` Best candidate per field + (only when a result was produced). + - ``config.snapshot.json`` Copy of the input config. + - ``run.log`` One-line status footer. + + SIGINT (Ctrl+C) is masked for the duration of this method so a + second Ctrl+C during persistence cannot leave half-written + artifacts. All files are written atomically (tmp + os.replace), + so even if SIGKILL or a power loss interrupts the process the + output_dir never contains a partially-written file (only a + ``.tmp`` sibling that the next run can ignore). Missing pieces + (e.g. ``best_prompts`` on early failure) are silently omitted. + """ + with _mask_sigint(): + cls._write_baseline_prompts(baseline_snapshot, output_dir) + cls._copy_config_snapshot(config_path, output_dir) + + if result is None: + cls._write_run_log( + output_dir=output_dir, + line=cls._render_failure_log_line(run_error), + ) + return + + try: + _atomic_write_text( + os.path.join(output_dir, "result.json"), + result.model_dump_json(indent=2, by_alias=True), + ) + except Exception: # pragma: no cover - defensive guard for write errors + _PROMPT_FILE_LOGGER.warning("failed to write result.json", exc_info=True) + + try: + summary_text = result.format_summary(output_dir=output_dir, update_source=update_source) + _atomic_write_text(os.path.join(output_dir, "summary.txt"), summary_text) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to write summary.txt", exc_info=True) + + cls._write_rounds_directory(result, output_dir) + cls._write_best_prompts(result, output_dir) + cls._write_run_log( + output_dir=output_dir, + line=cls._render_success_log_line(result), + ) + + @staticmethod + def _write_baseline_prompts(baseline_snapshot: dict[str, str], output_dir: str) -> None: + baseline_dir = os.path.join(output_dir, "baseline_prompts") + os.makedirs(baseline_dir, exist_ok=True) + for name, content in baseline_snapshot.items(): + path = os.path.join(baseline_dir, f"{name}.md") + try: + _atomic_write_text(path, content) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to write baseline prompt %s", name, exc_info=True) + + @staticmethod + def _write_best_prompts(result: OptimizeResult, output_dir: str) -> None: + best_dir = os.path.join(output_dir, "best_prompts") + os.makedirs(best_dir, exist_ok=True) + for name, content in result.best_prompts.items(): + path = os.path.join(best_dir, f"{name}.md") + try: + _atomic_write_text(path, content) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to write best prompt %s", name, exc_info=True) + + @staticmethod + def _write_rounds_directory(result: OptimizeResult, output_dir: str) -> None: + rounds_dir = os.path.join(output_dir, "rounds") + os.makedirs(rounds_dir, exist_ok=True) + for record in result.rounds: + path = os.path.join(rounds_dir, f"round_{record.round:03d}.json") + try: + _atomic_write_text(path, record.model_dump_json(indent=2, by_alias=True)) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to write round %s", record.round, exc_info=True) + + @staticmethod + def _copy_config_snapshot(config_path: str, output_dir: str) -> None: + target = os.path.join(output_dir, "config.snapshot.json") + try: + # Read + atomic-write rather than shutil.copyfile so an interrupted + # copy cannot leave a half-written ``config.snapshot.json``. + content = Path(config_path).read_text(encoding="utf-8") + _atomic_write_text(target, content) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to copy config snapshot", exc_info=True) + + @staticmethod + def _write_run_log(*, output_dir: str, line: str) -> None: + try: + _atomic_write_text( + os.path.join(output_dir, "run.log"), + line.rstrip("\n") + "\n", + ) + except Exception: # pragma: no cover + _PROMPT_FILE_LOGGER.warning("failed to write run.log", exc_info=True) + + @staticmethod + def _render_success_log_line(result: OptimizeResult) -> str: + return (f"{datetime.now(timezone.utc).isoformat()} status={result.status} " + f"algorithm={result.algorithm} " + f"baseline={result.baseline_pass_rate:.4f} " + f"best={result.best_pass_rate:.4f} " + f"delta={result.pass_rate_improvement:+.4f} " + f"rounds={result.total_rounds} " + f"duration_seconds={result.duration_seconds:.2f}") + + @staticmethod + def _render_failure_log_line(run_error: Optional[BaseException]) -> str: + msg = str(run_error) if run_error else "optimization failed before result" + return (f"{datetime.now(timezone.utc).isoformat()} status=FAILED " + f"error={msg!r}") + + @staticmethod + def _precheck_algorithm_name(config_path: str) -> None: + """Friendly fail-fast when ``algorithm.name`` is unknown. + + ``GepaReflectiveAlgo.name`` is declared as ``Literal["gepa_reflective"]`` + for future pydantic-discriminator-based union routing. The Literal + causes pydantic to reject unknown names with a ``literal_error`` that + does not list available algorithms. We pre-read the raw JSON, look up + ``algorithm.name`` against ``OPTIMIZER_REGISTRY``, and raise a + ``ValueError`` listing every registered algorithm before pydantic's + Literal check fires. If parsing fails or the field is absent we let + pydantic's normal error path handle it (so we do not duplicate + formatting errors). + """ + import json + + try: + with open(config_path, "r", encoding="utf-8") as f: + raw = json.load(f) + except (OSError, json.JSONDecodeError): + return # let pydantic / load_optimize_config surface the real cause + + try: + name = raw["optimize"]["algorithm"]["name"] + except (KeyError, TypeError): + return # malformed shape: pydantic will raise a structured error + + if not isinstance(name, str): + return # type error: let pydantic's normal validation handle it + + registered = OPTIMIZER_REGISTRY.list_registered() + if name not in registered: + raise ValueError(f"No optimizer registered for algorithm: {name!r}. " + f"Available algorithms: {registered}") + + @staticmethod + def _validate_inputs( + *, + config, + call_agent: CallAgent, + target_prompt: TargetPrompt, + train_dataset_path: str, + validation_dataset_path: str, + output_dir: str, + ) -> None: + """Startup-time fail-fast checks. + + Reports actionable error messages so misconfigurations surface before + any LLM call is made. + """ + if not output_dir or not isinstance(output_dir, str): + raise ValueError("output_dir is required and must be a non-empty path; " + "pass output_dir='runs/' or similar.") + + if not target_prompt.names(): + raise ValueError("TargetPrompt has no registered fields; " + "call .add_path(...) or .add_callback(...) before optimize().") + + # Accept async functions and partials wrapping a coroutine function. + is_async = inspect.iscoroutinefunction(call_agent) + if not is_async: + wrapped = getattr(call_agent, "__wrapped__", None) + is_async = wrapped is not None and inspect.iscoroutinefunction(wrapped) + if not is_async: + raise TypeError("call_agent must be an async callable (async def or " + "Callable returning Awaitable[str]); " + f"got {type(call_agent).__name__}.") + + # Normalize so trivially-different strings ('./x', 'x') still collide + # when they resolve to the same file (train-validation leakage guard). + train_norm = os.path.normpath(os.path.abspath(train_dataset_path)) + val_norm = os.path.normpath(os.path.abspath(validation_dataset_path)) + if train_norm == val_norm: + raise ValueError("train_dataset_path and validation_dataset_path resolve to the " + f"same file ({train_norm}); use distinct datasets to avoid " + "train-validation leakage.") + + # call_agent (black-box) mode can't supply session traces or + # tool intermediate_data. ``get_eval_metrics()`` normalizes both + # 'metrics' and 'criteria' encodings so this check is uniform. + for metric in config.evaluate.get_eval_metrics(): + if metric.metric_name in _DISALLOWED_METRICS_IN_CALL_AGENT_MODE: + raise ValueError(f"Metric '{metric.metric_name}' requires session " + "traces or tool intermediate data, which call_agent " + "(black-box) mode does not capture; remove it from " + "evaluate.metrics or switch to a response-based metric " + "(e.g. final_response_avg_score, llm_rubric_response, " + "llm_final_response).") + + # gepa merge degenerates to "pick one of two parents" with a single + # component, never producing new candidates. Warn instead of error + # so existing benign configs keep running; user gets a clear hint + # that merge_rounds_total will be 0. + algo = config.optimize.algorithm + if (getattr(algo, "name", None) == "gepa_reflective" and getattr(algo, "use_merge", False) + and len(target_prompt.names()) < 2): + warnings.warn( + "use_merge=true requires TargetPrompt to register at least 2 " + "fields. With a single field, gepa merge degenerates to " + "picking one of the two parents and never creates new " + "candidates (merge_rounds_total stays 0). Set use_merge=false " + "or register more prompt fields. See " + "examples/optimization/advanced_strategies/README.md §6.1.", + UserWarning, + stacklevel=2, + ) diff --git a/trpc_agent_sdk/evaluation/_base_optimizer.py b/trpc_agent_sdk/evaluation/_base_optimizer.py new file mode 100644 index 000000000..6d79027be --- /dev/null +++ b/trpc_agent_sdk/evaluation/_base_optimizer.py @@ -0,0 +1,123 @@ +# 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. +"""Abstract base class for prompt optimization algorithms.""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import TYPE_CHECKING +from typing import Any +from typing import Optional +from typing import Sequence + +from ._eval_callbacks import Callbacks +from ._optimize_config import FrameworkStopConfig +from ._optimize_config import OptimizeConfigFile +from ._optimize_result import OptimizeResult +from ._remote_eval_service import CallAgent +from ._target_prompt import TargetPrompt + +if TYPE_CHECKING: + from ._optimize_reporter import OptimizeReporter + + +class BaseOptimizer(ABC): + """Abstract base class for prompt optimization algorithms. + + Subclasses implement `run()` to execute one full optimization loop + against the supplied config, evaluator inputs, and TargetPrompt. + """ + + def __init__( + self, + *, + config: OptimizeConfigFile, + call_agent: CallAgent, + target_prompt: TargetPrompt, + train_dataset_path: str, + validation_dataset_path: str, + callbacks: Optional[Callbacks] = None, + output_dir: Optional[str] = None, + extra_stop_callbacks: Optional[Sequence[Any]] = None, + extra_gepa_callbacks: Optional[Sequence[Any]] = None, + ) -> None: + self.config = config + self.call_agent = call_agent + self.target_prompt = target_prompt + self.train_dataset_path = train_dataset_path + self.validation_dataset_path = validation_dataset_path + self.callbacks = callbacks + self.output_dir = output_dir + # Runtime-only hooks are not part of the JSON config schema + # because they're Python callables (SLO monitors, kill switches, + # custom telemetry sinks) whose identity is meaningful and + # cannot be serialised. Plain stoppers surface a generic + # ``"completed"`` stop_reason unless wrapped in + # ``_LabeledStopper``. + self.extra_stop_callbacks: list[Any] = (list(extra_stop_callbacks) if extra_stop_callbacks else []) + self.extra_gepa_callbacks: list[Any] = (list(extra_gepa_callbacks) if extra_gepa_callbacks else []) + + @abstractmethod + async def run( + self, + *, + reporter: Optional["OptimizeReporter"] = None, + ) -> OptimizeResult: + """Execute the optimization loop and return the final OptimizeResult. + + Args: + reporter: Progress sink for ``baseline_evaluated`` and + ``round_completed`` events. The facade always supplies + a non-None instance (``_NullReporter`` when + ``verbose=0``); subclasses may treat ``None`` as a noop + for direct invocations. + """ + + @staticmethod + def resolve_required_thresholds( + stop_config: FrameworkStopConfig, + metric_thresholds: dict[str, float], + ) -> dict[str, float]: + """Return the subset of thresholds the framework stop policy enforces. + + Resolution rules: + - ``required_metrics`` is None or empty list → ``{}`` (disabled). + - ``required_metrics == "all"`` → copy of all thresholds. + - non-empty list → ``metric_thresholds`` + filtered to listed names. Unknown names are silently dropped + (cross-field validation on :class:`OptimizeConfigFile` + already rejects them at config load time). + + Algorithms call this once per run and feed the result to + :meth:`metrics_meet_thresholds`. + """ + required = stop_config.required_metrics + if required is None: + return {} + if isinstance(required, list): + if not required: + return {} + allowed = set(required) + return {name: thr for name, thr in metric_thresholds.items() if name in allowed} + return dict(metric_thresholds) + + @staticmethod + def metrics_meet_thresholds( + metric_breakdown: dict[str, float], + required_thresholds: dict[str, float], + ) -> bool: + """True iff every required metric meets its threshold. + + Returns ``False`` when ``required_thresholds`` is empty so the + policy is a no-op when nothing is required. Callers obtain + ``required_thresholds`` from :meth:`resolve_required_thresholds` + for consistent "all / list / None / empty" semantics. + """ + if not required_thresholds: + return False + return all( + metric_breakdown.get(name, float("-inf")) >= threshold for name, threshold in required_thresholds.items()) diff --git a/trpc_agent_sdk/evaluation/_common.py b/trpc_agent_sdk/evaluation/_common.py new file mode 100644 index 000000000..dec902d26 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_common.py @@ -0,0 +1,39 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Common base models for evaluation. + +""" + +from __future__ import annotations + +import pydantic +from pydantic import alias_generators + + +class EvalBaseModel(pydantic.BaseModel): + model_config = pydantic.ConfigDict( + alias_generator=alias_generators.to_camel, + populate_by_name=True, + extra="forbid", + arbitrary_types_allowed=True, + ) diff --git a/trpc_agent_sdk/evaluation/_criterion_registry.py b/trpc_agent_sdk/evaluation/_criterion_registry.py new file mode 100644 index 000000000..3b6213d4c --- /dev/null +++ b/trpc_agent_sdk/evaluation/_criterion_registry.py @@ -0,0 +1,111 @@ +# 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. +"""Criterion type and registry. Register a custom match rule to replace built-in logic.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any +from typing import Callable +from typing import Optional + +from ._eval_criterion import FinalResponseCriterion +from ._eval_criterion import JSONCriterion +from ._eval_criterion import TextCriterion +from ._eval_criterion import ToolTrajectoryCriterion +from ._llm_criterion import LLMJudgeCriterion + + +class CriterionType(str, Enum): + """Built-in criterion types. Use with CRITERION_REGISTRY.register(type, match_fn).""" + TEXT = "text" + JSON = "json" + TOOL_TRAJECTORY = "tool_trajectory" + FINAL_RESPONSE = "final_response" + LLM_JUDGE = "llm_judge" + + +# (actual, expected) -> bool +MatchRule = Callable[[Any, Any], bool] + + +class _ReplaceWrapper: + + def __init__(self, match_rule: MatchRule) -> None: + self._match_rule = match_rule + + def matches(self, actual: Any, expected: Any) -> bool: + return self._match_rule(actual, expected) + + +class CriterionRegistry: + """Register a custom match rule to replace built-in criterion for a type. + + Example: + from trpc_agent_sdk.evaluation import CriterionType, CRITERION_REGISTRY + + def my_text_match(actual: str, expected: str) -> bool: + return actual.strip().lower() == expected.strip().lower() + + CRITERION_REGISTRY.register(CriterionType.TEXT, my_text_match) + """ + + def __init__(self) -> None: + self._overrides: dict[str, MatchRule] = {} + self._factories = { + "tool_trajectory": ToolTrajectoryCriterion.from_dict, + "toolTrajectory": ToolTrajectoryCriterion.from_dict, + "final_response": FinalResponseCriterion.from_dict, + "finalResponse": FinalResponseCriterion.from_dict, + "text": TextCriterion.from_dict, + "json": JSONCriterion.from_dict, + "llm_judge": LLMJudgeCriterion.from_dict, + "llmJudge": LLMJudgeCriterion.from_dict, + } + + def register(self, criterion_type: CriterionType, match_rule: MatchRule) -> None: + """Register a custom match rule for the criterion type. Replaces built-in matching.""" + key = criterion_type.value if isinstance(criterion_type, CriterionType) else str(criterion_type) + self._overrides[key] = match_rule + + def build(self, config: dict | None, metric_key: Optional[str] = None) -> Any: + """Build criterion from config. If a match rule was registered for this type, use it.""" + if not config or not isinstance(config, dict): + return None + + type_name: Optional[str] = None + sub_config: Optional[dict] = None + + explicit_type = config.get("type") or config.get("Type") + if explicit_type: + type_name = str(explicit_type).lower().replace("-", "_") + sub_config = config + elif metric_key == "tool_trajectory_avg_score": + type_name = "tool_trajectory" + sub_config = config.get("toolTrajectory") or config.get("tool_trajectory") + elif metric_key == "final_response_avg_score": + type_name = "final_response" + sub_config = config.get("finalResponse") or config.get("final_response") + elif metric_key in ("llm_final_response", "llm_rubric_response", "llm_rubric_knowledge_recall"): + type_name = "llm_judge" + sub_config = config.get("llmJudge") or config.get("llm_judge") + + if not type_name: + return None + + override = self._overrides.get(type_name) + if override is not None: + return _ReplaceWrapper(override) + + if sub_config and isinstance(sub_config, dict): + factory = self._factories.get(type_name) + if factory: + return factory(sub_config) + return None + + +# Default singleton: register custom match via CRITERION_REGISTRY.register(CriterionType.XXX, fn). +CRITERION_REGISTRY = CriterionRegistry() diff --git a/trpc_agent_sdk/evaluation/_eval_callbacks.py b/trpc_agent_sdk/evaluation/_eval_callbacks.py new file mode 100644 index 000000000..9feca7053 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_callbacks.py @@ -0,0 +1,416 @@ +# 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. +"""Evaluation lifecycle callbacks. Register hooks at inference/evaluate set/case boundaries.""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from dataclasses import field +from enum import Enum +from typing import Any +from typing import Callable +from typing import Optional +from typing import Union + +from ._eval_result import EvalCaseResult +from ._eval_service_base import EvaluateRequest +from ._eval_service_base import InferenceRequest +from ._eval_service_base import InferenceResult + + +class CallbackPoint(Enum): + """Lifecycle point for evaluation callbacks. Value is the Callback attribute name (snake_case).""" + + BEFORE_INFERENCE_SET = "before_inference_set" + AFTER_INFERENCE_SET = "after_inference_set" + BEFORE_INFERENCE_CASE = "before_inference_case" + AFTER_INFERENCE_CASE = "after_inference_case" + BEFORE_EVALUATE_SET = "before_evaluate_set" + AFTER_EVALUATE_SET = "after_evaluate_set" + BEFORE_EVALUATE_CASE = "before_evaluate_case" + AFTER_EVALUATE_CASE = "after_evaluate_case" + + +@dataclass +class EvalSetRunResult: + """Result of a single eval set run. Aligns with Go EvalSetRunResult.""" + + app_name: str = "" + eval_set_id: str = "" + eval_case_results: list[EvalCaseResult] = field(default_factory=list) + + +@dataclass +class CallbackResult: + """Result of a lifecycle callback. Context is passed to subsequent operations.""" + + context: Any = None + + +@dataclass +class BeforeInferenceSetArgs: + """Arguments for before inference set. Request can be modified by callbacks.""" + + request: InferenceRequest + + +@dataclass +class AfterInferenceSetArgs: + """Arguments for after inference set.""" + + request: InferenceRequest + results: list[InferenceResult] + error: Optional[Exception] + start_time: float + + +@dataclass +class BeforeInferenceCaseArgs: + """Arguments for before inference case.""" + + request: InferenceRequest + eval_case_id: str + session_id: str + + +@dataclass +class AfterInferenceCaseArgs: + """Arguments for after inference case.""" + + request: InferenceRequest + result: InferenceResult + error: Optional[Exception] + start_time: float + + +@dataclass +class BeforeEvaluateSetArgs: + """Arguments for before evaluate set. Request can be modified.""" + + request: EvaluateRequest + + +@dataclass +class AfterEvaluateSetArgs: + """Arguments for after evaluate set.""" + + request: EvaluateRequest + result: Optional[EvalSetRunResult] + error: Optional[Exception] + start_time: float + + +@dataclass +class BeforeEvaluateCaseArgs: + """Arguments for before evaluate case.""" + + request: EvaluateRequest + eval_case_id: str + + +@dataclass +class AfterEvaluateCaseArgs: + """Arguments for after evaluate case.""" + + request: EvaluateRequest + inference_result: InferenceResult + result: EvalCaseResult + error: Optional[Exception] + start_time: float + + +# (ctx, args) -> Optional[CallbackResult] | Any +CallbackFn = Callable[ + [dict[str, Any], Any], + Union[Optional[CallbackResult], Any], +] + + +class Callback: + """Group of optional lifecycle callbacks. Register any subset of the 8 hooks. + + Construct with keyword args only: Callback(before_evaluate_set=fn, ...). + """ + + __slots__ = ("_hooks", ) + + def __init__(self, **kwargs: Optional[CallbackFn]) -> None: + self._hooks = {p: kwargs.get(p.value) for p in CallbackPoint} + + def get(self, point: CallbackPoint) -> Optional[CallbackFn]: + """Return the hook for the given lifecycle point, or None if not set.""" + return self._hooks.get(point) + + +class Callbacks: + """Register and run evaluation lifecycle callbacks at inference/evaluate set/case boundaries. + + Example: + from trpc_agent_sdk.evaluation import Callbacks, Callback, CallbackResult, BeforeEvaluateSetArgs + + def before_eval_set(ctx, args: BeforeEvaluateSetArgs): + print("Start evaluating", len(args.request.inference_results), "cases") + return CallbackResult(context={"run_id": "xxx"}) + + callbacks = Callbacks() + callbacks.register("my_plugin", Callback(before_evaluate_set=before_eval_set)) + await AgentEvaluator.evaluate_eval_set(..., callbacks=callbacks) + """ + + def __init__(self) -> None: + self._hooks: dict[CallbackPoint, list[tuple[str, CallbackFn]]] = {p: [] for p in CallbackPoint} + + def register(self, name: str, callback: Optional[Callback]) -> "Callbacks": + """Register a named callback component. Name is used in error messages. Any subset of hooks can be set.""" + if not callback: + return self + for point in CallbackPoint: + fn = callback.get(point) + if fn is not None: + self._hooks[point].append((name, fn)) + return self + + def _register_point(self, point: CallbackPoint, name: str, fn: CallbackFn) -> "Callbacks": + return self.register(name, Callback(**{point.value: fn})) + + def register_before_inference_set(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.BEFORE_INFERENCE_SET, name, fn) + + def register_after_inference_set(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.AFTER_INFERENCE_SET, name, fn) + + def register_before_inference_case(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.BEFORE_INFERENCE_CASE, name, fn) + + def register_after_inference_case(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.AFTER_INFERENCE_CASE, name, fn) + + def register_before_evaluate_set(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.BEFORE_EVALUATE_SET, name, fn) + + def register_after_evaluate_set(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.AFTER_EVALUATE_SET, name, fn) + + def register_before_evaluate_case(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.BEFORE_EVALUATE_CASE, name, fn) + + def register_after_evaluate_case(self, name: str, fn: CallbackFn) -> "Callbacks": + return self._register_point(CallbackPoint.AFTER_EVALUATE_CASE, name, fn) + + def get_hooks(self, point: CallbackPoint) -> list[tuple[str, CallbackFn]]: + return self._hooks[point] + + +class CallbacksRunner: + """Runner for executing registered callbacks. Used by the framework only.""" + + def __init__(self, callbacks: Callbacks) -> None: + self._callbacks = callbacks + + def _wrap_error(self, point: str, idx: int, name: str, err: Exception) -> Exception: + out = RuntimeError(f"{point} callback[{idx}] ({name}): {err}") + out.__cause__ = err + return out + + def _get_context_from_result(self, result: Any) -> Any: + if result is None: + return None + if isinstance(result, CallbackResult): + return result.context + return None + + async def _call_with_recovery( + self, + ctx: dict[str, Any], + point: str, + idx: int, + name: str, + fn: Callable[..., Any], + args: Any, + ) -> tuple[Any, Optional[Exception]]: + try: + if inspect.iscoroutinefunction(fn): + out = await fn(ctx, args) + else: + out = fn(ctx, args) + return (out, None) + except Exception as e: + return (None, self._wrap_error(point, idx, name, e)) + + async def _run_hooks( + self, + hooks: list[tuple[str, Callable[..., Any]]], + point: str, + args: Any, + ctx: Optional[dict[str, Any]] = None, + ) -> tuple[Any, Optional[Exception]]: + """Execute a list of hooks in order; merge context from results; return (last_result, err).""" + if not hooks: + return (None, None) + run_ctx = ctx if ctx is not None else {} + last_result: Any = None + for idx, (name, fn) in enumerate(hooks): + result, err = await self._call_with_recovery(run_ctx, point, idx, name, fn, args) + if err is not None: + e = RuntimeError(f"execute {point} callbacks: {err}") + e.__cause__ = err + return (None, e) + if result is not None: + last_result = result + c = self._get_context_from_result(result) + if c is not None: + run_ctx["context"] = c + return (last_result, None) + + async def _run_impl( + self, + point: CallbackPoint, + args: Any, + ctx: Optional[dict[str, Any]] = None, + ) -> tuple[Any, Optional[Exception]]: + hooks = self._callbacks.get_hooks(point) + return await self._run_hooks(hooks, point.value, args, ctx) + + async def run_before_inference_set(self, request: InferenceRequest, run_ctx: dict[str, Any]) -> None: + _, err = await self._run_impl( + CallbackPoint.BEFORE_INFERENCE_SET, + BeforeInferenceSetArgs(request=request), + run_ctx, + ) + if err is not None: + raise err + + async def run_after_inference_set( + self, + request: InferenceRequest, + results: list[InferenceResult], + error: Optional[Exception], + start_time: float, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.AFTER_INFERENCE_SET, + AfterInferenceSetArgs( + request=request, + results=results, + error=error, + start_time=start_time, + ), + run_ctx, + ) + if err is not None: + raise err + + async def run_before_inference_case( + self, + request: InferenceRequest, + eval_case_id: str, + session_id: str, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.BEFORE_INFERENCE_CASE, + BeforeInferenceCaseArgs( + request=request, + eval_case_id=eval_case_id, + session_id=session_id, + ), + run_ctx, + ) + if err is not None: + raise err + + async def run_after_inference_case( + self, + request: InferenceRequest, + result: InferenceResult, + error: Optional[Exception], + start_time: float, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.AFTER_INFERENCE_CASE, + AfterInferenceCaseArgs( + request=request, + result=result, + error=error, + start_time=start_time, + ), + run_ctx, + ) + if err is not None: + raise err + + async def run_before_evaluate_set(self, request: EvaluateRequest, run_ctx: dict[str, Any]) -> None: + _, err = await self._run_impl( + CallbackPoint.BEFORE_EVALUATE_SET, + BeforeEvaluateSetArgs(request=request), + run_ctx, + ) + if err is not None: + raise err + + async def run_after_evaluate_set( + self, + request: EvaluateRequest, + result: EvalSetRunResult, + error: Optional[Exception], + start_time: float, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.AFTER_EVALUATE_SET, + AfterEvaluateSetArgs( + request=request, + result=result, + error=error, + start_time=start_time, + ), + run_ctx, + ) + if err is not None: + raise err + + async def run_before_evaluate_case( + self, + request: EvaluateRequest, + eval_case_id: str, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.BEFORE_EVALUATE_CASE, + BeforeEvaluateCaseArgs( + request=request, + eval_case_id=eval_case_id, + ), + run_ctx, + ) + if err is not None: + raise err + + async def run_after_evaluate_case( + self, + request: EvaluateRequest, + inference_result: InferenceResult, + result: EvalCaseResult, + error: Optional[Exception], + start_time: float, + run_ctx: dict[str, Any], + ) -> None: + _, err = await self._run_impl( + CallbackPoint.AFTER_EVALUATE_CASE, + AfterEvaluateCaseArgs( + request=request, + inference_result=inference_result, + result=result, + error=error, + start_time=start_time, + ), + run_ctx, + ) + if err is not None: + raise err diff --git a/trpc_agent_sdk/evaluation/_eval_case.py b/trpc_agent_sdk/evaluation/_eval_case.py new file mode 100644 index 000000000..8ac476d88 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_case.py @@ -0,0 +1,289 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation case data structures. + +This module defines the core data structures for evaluation cases, +adapted from Google ADK Python to work with TRPC Agent framework. +""" + +from __future__ import annotations + +from typing import Any +from typing import Literal +from typing import Optional +from typing import Union + +from pydantic import Field +from pydantic import model_validator + +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + +from ._common import EvalBaseModel + + +class IntermediateData(EvalBaseModel): + """Container for intermediate data during agent execution. + + Attributes: + tool_uses: List of tool/function calls made by the agent + tool_responses: List of tool/function responses received + intermediate_responses: Intermediate text responses during execution + """ + + tool_uses: list[FunctionCall] = Field(default_factory=list) + """Tool use trajectory in chronological order.""" + + tool_responses: list[FunctionResponse] = Field(default_factory=list) + """Tool response trajectory in chronological order.""" + + intermediate_responses: list[tuple[str, list[Part]]] = Field(default_factory=list) + """Intermediate responses generated during execution.""" + + +class InvocationEvent(EvalBaseModel): + """An event during agent invocation. + + Attributes: + author: Name of the agent that generated this event + content: Content of the event + """ + + author: str + """The name of the agent that authored this event.""" + + content: Optional[Content] + """The content of the event.""" + + +class InvocationEvents(EvalBaseModel): + """Container for events during an invocation. + + Attributes: + invocation_events: List of events that occurred + """ + + invocation_events: list[InvocationEvent] = Field(default_factory=list) + """A list of invocation events.""" + + +# Type alias for intermediate data +IntermediateDataType = Union[IntermediateData, InvocationEvents] + + +class Invocation(EvalBaseModel): + """Represents a single invocation (user query + agent response). + + This is the basic unit for evaluation, containing: + - User input + - Agent's final response + - Intermediate steps (tool calls, events) + + Attributes: + invocation_id: Unique identifier for this invocation + user_content: User's input content + final_response: Agent's final response + intermediate_data: Intermediate execution data + creation_timestamp: When this invocation was created + """ + + invocation_id: str = "" + """Unique identifier for the invocation.""" + + user_content: Content + """Content provided by the user in this invocation.""" + + final_response: Optional[Content] = None + """Final response from the agent.""" + + intermediate_data: Optional[IntermediateDataType] = None + """Intermediate steps generated during agent execution.""" + + creation_timestamp: float = 0.0 + """Timestamp for the current invocation.""" + + +class SessionInput(EvalBaseModel): + """Values that help initialize a Session. + + Attributes: + app_name: The name of the app + user_id: The user id + state: The initial state of the session + """ + + app_name: str + """The name of the app.""" + + user_id: str + """The user id.""" + + state: dict[str, Any] = Field(default_factory=dict) + """The state of the session.""" + + +# Type alias for static conversation +StaticConversation = list[Invocation] +"""A conversation where user queries are pre-defined.""" + +# Eval mode: default (run agent) or trace (use pre-recorded conversation as inference result) +EvalModeTrace: Literal["trace"] = "trace" + + +class ConversationScenario(EvalBaseModel): + """Scenario for dynamic conversation with simulated user. + + Attributes: + starting_prompt: Initial user message + conversation_plan: Plan for the conversation flow + """ + + starting_prompt: str + """Starting prompt for the conversation.""" + + conversation_plan: str + """A plan that user simulation system needs to follow.""" + + +class EvalCase(EvalBaseModel): + """An evaluation test case. + + Represents a complete test scenario with either: + - Static conversation (pre-defined queries and expected responses) + - Dynamic conversation scenario (for user simulation) + - Trace mode: pre-recorded conversation as inference result (no agent run) + + Attributes: + eval_id: Unique identifier for this test case + eval_mode: "trace" = use conversation/actual_conversation as-is; default = run agent + conversation: Static conversation (or expected in trace when actual_conversation is set) + actual_conversation: Actual trace for evaluation (trace mode only, aligned by turn with conversation) + conversation_scenario: Dynamic scenario for user simulation (non-trace only) + session_input: Initial session state + """ + + eval_id: str + """Unique identifier for the evaluation case.""" + + eval_mode: Optional[str] = Field(default=None, alias="evalMode") + """When \"trace\", inference uses pre-recorded conversation; actual_conversation allowed.""" + + conversation: Optional[StaticConversation] = None + """Static conversation; in trace mode with actual_conversation this is the expected.""" + + actual_conversation: Optional[StaticConversation] = Field(default=None, alias="actualConversation") + """Actual invocations for evaluation (trace mode only); aligned by turn with conversation when both set.""" + + conversation_scenario: Optional[ConversationScenario] = None + """A conversation scenario for user simulation (non-trace only).""" + + session_input: Optional[SessionInput] = None + """Session input for initialization.""" + + context_messages: Optional[list[Content]] = Field(default=None, alias="contextMessages") + """Per-case context (Content: parts+role), prepended to conversation, not persisted.""" + + creation_timestamp: float = 0.0 + """The time when this eval case was created.""" + + @model_validator(mode="after") + def ensure_conversation_xor_conversation_scenario(self) -> EvalCase: + """Trace: actual_conversation is required (conversation optional as reference). + Default: conversation xor conversation_scenario. + + Trace-mode legal shapes (after Bug 3.2 strict fix): + * actual_conversation only -> scenario 3 (no reference) + * actual_conversation + conversation -> scenario 1 (full comparison) + + The legacy shape of providing only `conversation` under eval_mode='trace' + is now rejected because the field would have to serve as both the + recorded trace (actual) and the reference (expected), which is ambiguous + and causes silent evaluation errors in reference-based metrics + (see docs/superpowers/specs/2026-05-06-evaluator-trace-metric-strict-compat-design.md). + """ + is_trace = self.eval_mode == EvalModeTrace + if is_trace: + if self.conversation_scenario is not None: + raise ValueError("conversation_scenario is not allowed when eval_mode is \"trace\"") + if not self.actual_conversation: + raise ValueError("eval_mode='trace' requires `actual_conversation` field. " + "If the provided `conversation` is a recorded trace, move it to " + "`actual_conversation`. If it's a reference answer, also provide " + "`actual_conversation` for scenario-1 full comparison. " + "See Bug 3.2 of evaluator-trace-metric-strict-compat-design.") + return self + if (self.conversation is None) == (self.conversation_scenario is None): + raise ValueError("Exactly one of conversation and conversation_scenario must be provided") + return self + + +def get_all_tool_calls(intermediate_data: Optional[IntermediateDataType], ) -> list[FunctionCall]: + """Extract all tool calls from intermediate data. + + Args: + intermediate_data: The intermediate data to extract from + + Returns: + List of function calls + """ + if not intermediate_data: + return [] + + tool_calls = [] + if isinstance(intermediate_data, IntermediateData): + tool_calls = intermediate_data.tool_uses + elif isinstance(intermediate_data, InvocationEvents): + for invocation_event in intermediate_data.invocation_events: + if invocation_event.content and invocation_event.content.parts: + for p in invocation_event.content.parts: + if p.function_call: + tool_calls.append(p.function_call) + + return tool_calls + + +def get_all_tool_responses(intermediate_data: Optional[IntermediateDataType], ) -> list[FunctionResponse]: + """Extract all tool responses from intermediate data. + + Args: + intermediate_data: The intermediate data to extract from + + Returns: + List of function responses + """ + if not intermediate_data: + return [] + + tool_responses = [] + if isinstance(intermediate_data, IntermediateData): + tool_responses = intermediate_data.tool_responses + elif isinstance(intermediate_data, InvocationEvents): + for invocation_event in intermediate_data.invocation_events: + if invocation_event.content and invocation_event.content.parts: + for p in invocation_event.content.parts: + if p.function_response: + tool_responses.append(p.function_response) + + return tool_responses diff --git a/trpc_agent_sdk/evaluation/_eval_config.py b/trpc_agent_sdk/evaluation/_eval_config.py new file mode 100644 index 000000000..e6bb881d7 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_config.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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation configuration.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from pydantic import Field + +from ._common import EvalBaseModel +from ._eval_metrics import EvalMetric +from ._eval_metrics import PrebuiltMetrics + + +def _normalize_criterion_for_metric(metric_name: str, value: Any) -> Optional[dict]: + """From criteria value object: return criterion dict or build from strategy alias.""" + if not isinstance(value, dict): + return None + criterion = value.get("criterion") or value.get("Criterion") + if criterion is not None and isinstance(criterion, dict): + return criterion + strategy = value.get("strategy") or value.get("Strategy") + if strategy is not None and isinstance(strategy, dict): + if metric_name == PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value: + return {"toolTrajectory": strategy} + if metric_name == PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + return {"finalResponse": strategy} + return {"strategy": strategy} + return None + + +def _threshold_from_value(value: Any) -> float: + """Threshold from criteria value: number, or object with threshold / Threshold.""" + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, dict): + t = value.get("threshold") or value.get("Threshold") + if t is not None: + return float(t) + return 1.0 + + +class EvalConfig(EvalBaseModel): + """Evaluation config. + + Backward compatible: criteria value may be number (threshold only). + """ + + criteria: dict[str, Any] = Field( + default_factory=dict, + description=("Metric name -> threshold (number) or " + "{ threshold?, criterion? | strategy? }."), + ) + metrics: Optional[list[Any]] = Field( + default=None, + description=("Optional metrics array; when set, used instead of criteria. " + "Item: metricName/metric_name, threshold, criterion."), + ) + num_runs: int = Field(default=1, description="Number of runs per case.") + user_simulator_config: Optional[Any] = Field(default=None, description="User simulator config.") + + def get_eval_metrics(self) -> list[EvalMetric]: + """Build EvalMetric list from metrics (if set) or criteria. + + Accepts camelCase and snake_case keys. + """ + if self.metrics: + out = [] + for m in self.metrics: + if not isinstance(m, dict): + continue + name = m.get("metricName") or m.get("metric_name") + if not name: + continue + threshold = _threshold_from_value(m) + criterion = m.get("criterion") or m.get("Criterion") + if not isinstance(criterion, dict): + criterion = _normalize_criterion_for_metric(name, m) + out.append(EvalMetric(metric_name=name, threshold=threshold, criterion=criterion)) + return out + + out = [] + for name, value in self.criteria.items(): + threshold = _threshold_from_value(value) + criterion = _normalize_criterion_for_metric(name, value) if isinstance(value, dict) else None + out.append(EvalMetric(metric_name=name, threshold=threshold, criterion=criterion)) + return out diff --git a/trpc_agent_sdk/evaluation/_eval_criterion.py b/trpc_agent_sdk/evaluation/_eval_criterion.py new file mode 100644 index 000000000..6e4cbc24d --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_criterion.py @@ -0,0 +1,469 @@ +# 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. +"""Evaluation criterion definitions. + +Defines TextCriterion, JSONCriterion, ToolTrajectoryCriterion, and +FinalResponseCriterion for string, JSON, tool trajectory, and final response +comparison in evaluation. +""" + +from __future__ import annotations + +import copy +from typing import Any +from typing import Callable +from typing import Literal +from typing import Optional + +from pydantic import AliasChoices +from pydantic import Field +from pydantic import field_validator + +from ._common import EvalBaseModel + + +class TextCriterion(EvalBaseModel): + """Criterion for comparing two strings (e.g. tool name, final response text). + + Evaluation order: ignore (skip) -> compare (custom) -> match strategy. + """ + + match: Literal["exact", "contains", "regex"] = Field( + default="exact", + description="Match strategy: exact, contains, or regex.", + validation_alias=AliasChoices("match", "match_strategy"), + ) + case_insensitive: bool = Field( + default=False, + description="If True, ignore case when comparing.", + ) + ignore: bool = Field( + default=False, + description="If True, skip comparison and always treat as match.", + ) + compare: Optional[Callable[[str, str], bool]] = Field( + default=None, + description=("Custom compare (actual, expected) -> bool. When set, overrides " + "match strategy. Not loadable from JSON; set in code only."), + exclude=True, + ) + + @field_validator("match", mode="before") + @classmethod + def _normalize_match(cls, v: object) -> object: + """Normalize match string to lowercase and strip whitespace.""" + if isinstance(v, str): + return v.strip().lower() + return v + + def matches(self, actual: str, expected: str) -> bool: + """Return True if actual matches expected under this criterion. + + Args: + actual: The actual string (e.g. from agent output). + expected: The expected string (e.g. from eval set). + + Returns: + True if match, False otherwise. None is treated as empty string. + """ + if self.ignore: + return True + a = actual if actual is not None else "" + e = expected if expected is not None else "" + if self.compare is not None: + return self.compare(a, e) + if self.case_insensitive: + a, e = a.lower(), e.lower() + if self.match == "exact": + return a == e + if self.match == "contains": + return e in a + if self.match == "regex": + import re + try: + return bool(re.search(e, a)) + except re.error: + return False + return a == e + + @classmethod + def from_dict(cls, d: dict | None) -> TextCriterion | None: + """Build TextCriterion from a config dict (e.g. from JSON). + + Args: + d: Config dict. Keys: match or match_strategy, case_insensitive or + caseInsensitive, ignore. compare cannot be set from dict. + + Returns: + TextCriterion instance, or None if d is None. + """ + if not d: + return None + return cls.model_validate(d) + + +class JSONCriterion(EvalBaseModel): + """Criterion for comparing two JSON-like values (e.g. tool arguments, result). + + Evaluation order: ignore (skip) -> compare (custom) -> exact with + ignore_tree and number_tolerance. + """ + + match: Literal["exact"] = Field( + default="exact", + description="Match strategy; only exact is supported.", + validation_alias=AliasChoices("match", "match_strategy"), + ) + ignore_tree: Optional[dict[str, Any]] = Field( + default=None, + description=("Keys to remove before compare. Leaf value True means drop that key " + "(e.g. {\"id\": true, \"meta\": {\"ts\": true}})."), + validation_alias=AliasChoices("ignore_tree", "ignoreTree"), + ) + number_tolerance: Optional[float] = Field( + default=None, + description="Numeric comparison tolerance; default 1e-6 when None.", + validation_alias=AliasChoices("number_tolerance", "numberTolerance"), + ) + ignore: bool = Field( + default=False, + description="If True, skip comparison and always treat as match.", + ) + compare: Optional[Callable[[Any, Any], bool]] = Field( + default=None, + description=("Custom compare (actual, expected) -> bool. When set, overrides " + "built-in logic. Not loadable from JSON; set in code only."), + exclude=True, + ) + + @field_validator("match", mode="before") + @classmethod + def _normalize_match(cls, v: object) -> object: + """Normalize match string to lowercase and strip whitespace.""" + if isinstance(v, str): + return v.strip().lower() + return v + + def matches(self, actual: Any, expected: Any) -> bool: + """Return True if actual matches expected under this criterion. + + Args: + actual: The actual value (e.g. tool args or result). + expected: The expected value (e.g. from eval set). + + Returns: + True if match, False otherwise. + """ + if self.ignore: + return True + if self.compare is not None: + return self.compare(actual, expected) + a = self._normalize_to_dict(actual) + e = self._normalize_to_dict(expected) + if self.ignore_tree: + a = self._apply_ignore_tree(a, self.ignore_tree) + e = self._apply_ignore_tree(e, self.ignore_tree) + return self._json_deep_equal(a, e) + + def _normalize_to_dict(self, val: Any) -> Any: + """Normalize value to dict for comparison; leave other types as-is.""" + if val is None: + return None + if isinstance(val, dict): + return copy.deepcopy(val) + if hasattr(val, "items"): + return dict(val) + return val + + def _apply_ignore_tree(self, obj: Any, tree: dict[str, Any]) -> Any: + """Return a copy of obj with keys in tree removed (recursive).""" + if not isinstance(obj, dict): + return obj + out = copy.deepcopy(obj) + for key, sub in tree.items(): + if key not in out: + continue + if sub is True: + del out[key] + elif isinstance(sub, dict) and isinstance(out.get(key), dict): + out[key] = self._apply_ignore_tree(out[key], sub) + return out + + def _json_deep_equal(self, actual: Any, expected: Any) -> bool: + """Recursive equality using self.number_tolerance for numeric comparison.""" + if actual is None and expected is None: + return True + if type(actual) is not type(expected): + return False + tol = 1e-6 if self.number_tolerance is None else self.number_tolerance + if isinstance(actual, (int, float)) and isinstance(expected, (int, float)): + return abs(float(actual) - float(expected)) <= tol + if isinstance(actual, dict) and isinstance(expected, dict): + if set(actual.keys()) != set(expected.keys()): + return False + return all(self._json_deep_equal(actual[k], expected[k]) for k in actual) + if isinstance(actual, list) and isinstance(expected, list): + if len(actual) != len(expected): + return False + return all(self._json_deep_equal(actual[i], expected[i]) for i in range(len(actual))) + return actual == expected + + @classmethod + def from_dict(cls, d: dict | None) -> JSONCriterion | None: + """Build JSONCriterion from a config dict (e.g. from JSON). + + Args: + d: Config dict. Keys: match or match_strategy, ignore_tree or + ignoreTree, number_tolerance or numberTolerance, ignore. + compare cannot be set from dict. + + Returns: + JSONCriterion instance, or None if d is None. + """ + if not d: + return None + return cls.model_validate(d) + + +class ToolTrajectoryCriterion(EvalBaseModel): + """Criterion for comparing tool call trajectories (name, arguments, result). + + Each tool uses a strategy: name (TextCriterion), arguments (JSONCriterion), + result (JSONCriterion). default applies to all tools; overrides per tool name. + """ + + default: Optional[dict[str, Any]] = Field( + default=None, + description=("Default strategy for all tools: name, arguments, result " + "(each a dict for TextCriterion or JSONCriterion)."), + validation_alias=AliasChoices("default", "default_strategy", "defaultStrategy"), + ) + overrides: Optional[dict[str, dict[str, Any]]] = Field( + default=None, + description=("Per-tool overrides: tool_name -> { name?, arguments?, result? }. " + "Overrides take precedence over default."), + validation_alias=AliasChoices("overrides", "tool_strategy", "toolStrategy"), + ) + order_sensitive: bool = Field( + default=False, + description=("If True, actual tool calls must match expected in order. " + "If False, matching may be out-of-order (e.g. bipartite matching)."), + validation_alias=AliasChoices("order_sensitive", "orderSensitive"), + ) + subset_matching: bool = Field( + default=False, + description=("If True, expected is a subset: actual may have extra tool calls; " + "all expected tools must still match. If False, counts must match."), + validation_alias=AliasChoices("subset_matching", "subsetMatching"), + ) + compare: Optional[Callable[[Any, Any], bool]] = Field( + default=None, + description=("Custom compare (actual_tool_calls, expected_tool_calls) -> bool. " + "When set, overrides built-in per-tool comparison. Not loadable from JSON."), + exclude=True, + ) + + def get_strategy_for_tool(self, tool_name: str) -> dict[str, Any]: + """Return merged strategy for the tool (overrides override default). + + Args: + tool_name: Name of the tool (e.g. get_weather). + + Returns: + Dict with optional keys name, arguments, result (each a criterion + config dict). Missing keys mean no criterion (evaluator uses default). + """ + base = self.default or {} + override = (self.overrides or {}).get(tool_name) or {} + merged = dict(base) + for key, value in override.items(): + if value is not None: + merged[key] = value + return merged + + def _pair_matches(self, actual_one: Any, expected_one: Any) -> bool: + """Return True if one actual tool call matches one expected under strategy.""" + name_a = getattr(actual_one, "name", None) or "" + name_e = getattr(expected_one, "name", None) or "" + strategy = self.get_strategy_for_tool(name_a or name_e or "") + name_c = TextCriterion.from_dict(strategy.get("name")) or TextCriterion() + args_c = JSONCriterion.from_dict(strategy.get("arguments")) or JSONCriterion() + if not name_c.matches(name_a, name_e): + return False + args_a = getattr(actual_one, "args", None) + args_e = getattr(expected_one, "args", None) + if not args_c.matches(args_a, args_e): + return False + return True + + def matches( + self, + actual_tool_calls: list[Any], + expected_tool_calls: list[Any], + ) -> bool: + """Return True if actual tool call list matches expected under this criterion. + + Uses compare if set; else order_sensitive / subset_matching and + per-tool strategy (name + arguments) for each pair. + + Args: + actual_tool_calls: List of tool calls (each has .name, .args). + expected_tool_calls: List of expected tool calls. + + Returns: + True if match, False otherwise. + """ + if self.compare is not None: + return self.compare(actual_tool_calls, expected_tool_calls) + if not expected_tool_calls: + return not actual_tool_calls if not self.subset_matching else True + if not self.subset_matching and len(actual_tool_calls) != len(expected_tool_calls): + return False + if self.subset_matching and len(actual_tool_calls) < len(expected_tool_calls): + return False + + if self.order_sensitive: + if self.subset_matching: + j = 0 + for exp in expected_tool_calls: + while j < len(actual_tool_calls): + if self._pair_matches(actual_tool_calls[j], exp): + j += 1 + break + j += 1 + else: + return False + return True + if len(actual_tool_calls) != len(expected_tool_calls): + return False + return all(self._pair_matches(a, e) for a, e in zip(actual_tool_calls, expected_tool_calls)) + + # Not order_sensitive: find a 1-1 matching (greedy). + used = [False] * len(actual_tool_calls) + for exp in expected_tool_calls: + found = False + for i, act in enumerate(actual_tool_calls): + if used[i]: + continue + if self._pair_matches(act, exp): + used[i] = True + found = True + break + if not found: + return False + return True + + @classmethod + def from_dict(cls, d: dict | None) -> ToolTrajectoryCriterion | None: + """Build ToolTrajectoryCriterion from a config dict (e.g. from JSON). + + Args: + d: Config dict with default or default_strategy, overrides or + tool_strategy. + + Returns: + ToolTrajectoryCriterion instance, or None if d is None. + """ + if not d: + return None + return cls.model_validate(d) + + +class FinalResponseCriterion(EvalBaseModel): + """Criterion for comparing agent final responses (e.g. Content or text). + + Supports text comparison and/or JSON comparison; when both are set, + both must pass (AND). Compare overrides built-in logic when set. + """ + + text: Optional[dict[str, Any]] = Field( + default=None, + description=("Text comparison strategy: dict for TextCriterion (match, " + "case_insensitive, ignore)."), + validation_alias=AliasChoices("text", "text_strategy", "textStrategy"), + ) + json_config: Optional[dict[str, Any]] = Field( + default=None, + description=("JSON comparison strategy: dict for JSONCriterion (ignore_tree, " + "number_tolerance, ignore). Content is parsed as JSON then compared. " + "When both text and json_config are set, both must match."), + validation_alias=AliasChoices("json", "json_strategy", "jsonStrategy"), + ) + compare: Optional[Callable[[Any, Any], bool]] = Field( + default=None, + description=("Custom compare (actual, expected) -> bool. When set, overrides " + "text and json. Not loadable from JSON; set in code only."), + exclude=True, + ) + + def _content_to_text(self, value: Any) -> str: + """Normalize value to string; Content-like uses parts[].text.""" + if value is None: + return "" + if isinstance(value, str): + return value + parts = getattr(value, "parts", None) + if parts is not None: + return "\n".join(getattr(p, "text", "") or "" for p in parts) + return str(value) + + def _text_to_json(self, raw: Any) -> Any: + """Parse value to JSON for json strategy; on failure return None.""" + s = self._content_to_text(raw) if not isinstance(raw, (dict, list)) else raw + if isinstance(s, (dict, list)): + return s + s = (s or "").strip() + if not s: + return None + import json + try: + return json.loads(s) + except (json.JSONDecodeError, TypeError): + return None + + def matches(self, actual: Any, expected: Any) -> bool: + """Return True if actual final response matches expected. + + Compare overrides when set. When both text and json_config are set, + both checks must pass. Returns False when neither strategy is set. + """ + if self.compare is not None: + return self.compare(actual, expected) + if self.text is None and self.json_config is None: + return False + text_ok = True + json_ok = True + if self.text is not None: + a = self._content_to_text(actual) + e = self._content_to_text(expected) + text_c = TextCriterion.from_dict(self.text) + text_ok = text_c.matches(a, e) + if self.json_config is not None: + a_j = self._text_to_json(actual) + e_j = self._text_to_json(expected) + json_c = JSONCriterion.from_dict(self.json_config) + json_ok = json_c.matches(a_j, e_j) + if self.text is not None and self.json_config is not None: + return text_ok and json_ok + if self.json_config is not None: + return json_ok + return text_ok + + @classmethod + def from_dict(cls, d: dict | None) -> FinalResponseCriterion | None: + """Build FinalResponseCriterion from a config dict (e.g. from JSON). + + Args: + d: Config dict. Keys: text or text_strategy or textStrategy (dict for + TextCriterion), json or json_strategy or jsonStrategy (dict for + JSONCriterion). compare cannot be set from dict. + + Returns: + FinalResponseCriterion instance, or None if d is None. + """ + if d is None: + return None + return cls.model_validate(d) diff --git a/trpc_agent_sdk/evaluation/_eval_metrics.py b/trpc_agent_sdk/evaluation/_eval_metrics.py new file mode 100644 index 000000000..e1a22487e --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_metrics.py @@ -0,0 +1,131 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation metrics definitions.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any +from typing import Optional + +from pydantic import Field +from pydantic import field_serializer + +from ._common import EvalBaseModel +from ._llm_criterion import sanitize_criterion_for_export + + +class EvalStatus(Enum): + """Status of evaluation.""" + PASSED = 1 + FAILED = 2 + NOT_EVALUATED = 3 + + +class PrebuiltMetrics(Enum): + """Pre-built evaluation metrics.""" + TOOL_TRAJECTORY_AVG_SCORE = "tool_trajectory_avg_score" + """Average score for tool call trajectory matching.""" + + FINAL_RESPONSE_AVG_SCORE = "final_response_avg_score" + """Binary score for final response matching (criterion-based).""" + + RESPONSE_MATCH_SCORE = "response_match_score" + """Score for response content matching using Rouge-1.""" + + RESPONSE_EVALUATION_SCORE = "response_evaluation_score" + """Overall response quality evaluation.""" + + LLM_FINAL_RESPONSE = "llm_final_response" + """LLM judge for final response (valid/invalid).""" + + LLM_RUBRIC_RESPONSE = "llm_rubric_response" + """LLM rubric-based response quality.""" + + LLM_RUBRIC_KNOWLEDGE_RECALL = "llm_rubric_knowledge_recall" + """LLM rubric knowledge recall.""" + + +class Interval(EvalBaseModel): + """Represents a range of numeric values, e.g. [0, 1] or (2, 3) or [-1, 6).""" + + min_value: float = Field(description="The smaller end of the interval.") + + open_at_min: bool = Field( + default=False, + description=("The interval is Open on the min end. The default value is False, " + "which means that we assume that the interval is Closed."), + ) + + max_value: float = Field(description="The larger end of the interval.") + + open_at_max: bool = Field( + default=False, + description=("The interval is Open on the max end. The default value is False, " + "which means that we assume that the interval is Closed."), + ) + + +class MetricValueInfo(EvalBaseModel): + """Information about the type of metric value.""" + + interval: Optional[Interval] = Field( + default=None, + description="The values represented by the metric are of type interval.", + ) + + +class MetricInfo(EvalBaseModel): + """Information about the metric that are used for Evals.""" + + metric_name: str = Field(description="The name of the metric.") + + description: str = Field(default=None, description="A 2 to 3 line description of the metric.") + + metric_value_info: MetricValueInfo = Field( + description="Information on the nature of values supported by the metric.") + + +class EvalMetric(EvalBaseModel): + """Single evaluation metric: name, pass/fail threshold, optional criterion config. + + Attributes: + metric_name: Name of the metric + threshold: Threshold value for pass/fail + criterion: Optional criterion config for evaluator + """ + + metric_name: str = Field(description="The name of the metric.") + + threshold: float = Field(description="Threshold value for this metric.") + + criterion: Optional[dict[str, Any]] = Field( + default=None, + description=("Optional. Keys: toolTrajectory/tool_trajectory, " + "finalResponse/final_response. Evaluator uses the key for its metric."), + ) + + @field_serializer("criterion") + def _serialize_criterion(self, value: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + """Strip api_key from nested llmJudge/judgeModel when exporting (avoid leaking secrets).""" + return sanitize_criterion_for_export(value) diff --git a/trpc_agent_sdk/evaluation/_eval_pass.py b/trpc_agent_sdk/evaluation/_eval_pass.py new file mode 100644 index 000000000..fc0bf693d --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_pass.py @@ -0,0 +1,53 @@ +# 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. +"""Pass@k and pass^k: basic formulas. Use with (n, c) from AgentEvaluator.parse_pass_nc.""" + +from __future__ import annotations + +import math + + +def pass_at_k(n: int, c: int, k: int) -> float: + """Probability that at least one of k attempts succeeds. pass@k = 1 - C(n-c,k)/C(n,k).""" + if n < 0: + raise ValueError("n must be >= 0") + if k <= 0: + raise ValueError("k must be >= 1") + if c < 0: + raise ValueError("c must be >= 0") + if c > n: + raise ValueError("c cannot exceed n") + if k > n: + raise ValueError("k cannot exceed n") + if c == 0: + return 0.0 + if n - c < k: + return 1.0 + nf, cf, kf = float(n), float(c), float(k) + a = math.lgamma(nf - cf + 1) + b = math.lgamma(nf - kf + 1) + d = math.lgamma(nf - cf - kf + 1) + e = math.lgamma(nf + 1) + log_p = a + b - d - e + return -math.expm1(log_p) + + +def pass_hat_k(n: int, c: int, k: int) -> float: + """Probability that all k consecutive runs succeed. pass^k = (c/n)^k.""" + if n <= 0: + raise ValueError("n must be > 0") + if k <= 0: + raise ValueError("k must be >= 1") + if c < 0: + raise ValueError("c must be >= 0") + if c > n: + raise ValueError("c cannot exceed n") + if c == 0: + return 0.0 + if c == n: + return 1.0 + p = float(c) / float(n) + return math.exp(float(k) * math.log(p)) diff --git a/trpc_agent_sdk/evaluation/_eval_result.py b/trpc_agent_sdk/evaluation/_eval_result.py new file mode 100644 index 000000000..700bb0e4b --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_result.py @@ -0,0 +1,313 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation results data structures.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from pydantic import Field + +from ._common import EvalBaseModel +from ._eval_case import Invocation +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus + + +class NamedScoreResult(EvalBaseModel): + """One judge model's per-invocation score, used inside PerInvocationResult.per_model_scores. + + Attributes: + model_name: Judge model name. + provider_name: Provider name; empty when unset. + score: Numeric score from this model after SamplesAggregator on its own samples. + reason: Reason text from the judge model (or exception text on soft failure). + rubric_scores: Per-rubric scores (rubric metrics). Use _llm_criterion.RubricScore. + passed: True iff score >= metric.threshold. + """ + + model_name: str = Field(default="", description="Judge model name.") + provider_name: str = Field(default="", description="Provider name.") + score: float = Field(default=0.0, description="Score from this model.") + reason: str = Field(default="", description="Reason from this model.") + rubric_scores: list[Any] = Field( + default_factory=list, + description="Per-rubric scores from this model (rubric metrics).", + ) + passed: bool = Field(default=False, description="True iff score >= threshold.") + + +class PerInvocationResult(EvalBaseModel): + """Result for a single invocation. + + Attributes: + actual_invocation: The actual invocation from agent + expected_invocation: The expected invocation (reference) + score: The score for this invocation + eval_status: The evaluation status + reason: Optional reason from LLM judge (for LLM metrics). + rubric_scores: Optional per-rubric scores (for rubric-based LLM metrics). + """ + + actual_invocation: Invocation + expected_invocation: Optional[Invocation] = None + score: Optional[float] = None + eval_status: EvalStatus = EvalStatus.NOT_EVALUATED + reason: Optional[str] = Field(default=None, description="Reason from judge (LLM metrics).") + rubric_scores: Optional[list[Any]] = Field( + default=None, + description="Per-rubric scores (LLM rubric metrics). Use _llm_criterion.RubricScore.", + ) + per_model_scores: Optional[list[NamedScoreResult]] = Field( + default=None, + description=("Per-judge-model breakdown for multi-model LLM judge metrics. " + "None for single-model or non-LLM metrics (back-compatible)."), + ) + + +class EvaluationResult(EvalBaseModel): + """Result of evaluating multiple invocations. + + Attributes: + overall_score: Overall score across all invocations + overall_eval_status: Overall evaluation status + per_invocation_results: Detailed results per invocation + """ + + overall_score: Optional[float] = None + """Overall score based on each invocation.""" + + overall_eval_status: EvalStatus = EvalStatus.NOT_EVALUATED + """Overall status based on each invocation.""" + + per_invocation_results: list[PerInvocationResult] = [] + """Detailed results per invocation.""" + + +class EvalMetricResultDetails(EvalBaseModel): + """Additional metric-specific info (reason, score, rubric_scores).""" + + reason: Optional[str] = Field(default=None, description="Reason for the metric result (e.g. LLM judge).") + score: Optional[float] = Field(default=None, description="Score for this metric result.") + rubric_scores: Optional[list[Any]] = Field( + default=None, + description="Per-rubric scores (LLM rubric metrics).", + ) + + +class EvalMetricResult(EvalMetric): + """Result of evaluating a metric. + + Attributes: + score: The computed score + eval_status: Whether the evaluation passed or failed + details: Nested details (reason, score, rubric_scores). + """ + + score: Optional[float] = Field( + default=None, + description="Score obtained after evaluating the metric.", + ) + + eval_status: EvalStatus = Field(description="The status of this evaluation.") + + details: Optional[EvalMetricResultDetails] = Field( + default=None, + description="Metric-specific details (reason, rubric_scores).", + ) + + +class EvalMetricResultPerInvocation(EvalBaseModel): + """Evaluation metric results per invocation. + + Attributes: + actual_invocation: The actual invocation + expected_invocation: The expected invocation + eval_metric_results: Results for each metric + """ + + actual_invocation: Invocation = Field(description="The actual invocation from the agent.") + + expected_invocation: Optional[Invocation] = Field(default=None, description="The expected invocation (reference).") + + eval_metric_results: list[EvalMetricResult] = Field(default_factory=list, + description="Evaluation results for each metric.") + + +class EvalCaseResult(EvalBaseModel): + """Results for a single evaluation case. + + Attributes: + eval_set_id: The eval set this case belongs to + eval_id: The eval case identifier + final_eval_status: Overall pass/fail status + overall_eval_metric_results: Overall results per metric + eval_metric_result_per_invocation: Detailed results per invocation + session_id: Session ID used during evaluation + user_id: User ID used during evaluation + """ + + eval_set_id: str = "" + """The eval set id.""" + + eval_id: str = "" + """The eval case id.""" + + run_id: Optional[int] = Field( + default=None, + description="1-based run index when num_runs > 1.", + ) + + final_eval_status: EvalStatus + """Final eval status for this eval case.""" + + error_message: Optional[str] = Field( + default=None, + description="Error message when evaluation execution failed.", + ) + + overall_eval_metric_results: list[EvalMetricResult] + """Overall result for each metric.""" + + eval_metric_result_per_invocation: list[EvalMetricResultPerInvocation] + """Result for each metric on per invocation basis.""" + + session_id: str + """Session id used during evaluation.""" + + user_id: Optional[str] = None + """User id used during evaluation.""" + + session_details: Optional[Any] = None + """Session details generated during evaluation.""" + + +class EvalStatusCounts(EvalBaseModel): + """Counts of evaluation statuses.""" + + passed: int = Field(default=0, description="Count of passed.") + failed: int = Field(default=0, description="Count of failed.") + not_evaluated: int = Field(default=0, description="Count of not evaluated.") + + +class EvalMetricRunSummary(EvalBaseModel): + """Metric result in a single run.""" + + metric_name: str = Field(description="Metric name.") + score: float = Field(description="Score for this metric in the run.") + eval_status: EvalStatus = Field(description="Eval status for this metric.") + threshold: float = Field(description="Threshold used.") + + +class EvalMetricSummary(EvalBaseModel): + """Metric summary across samples (e.g. across runs).""" + + metric_name: str = Field(description="Metric name.") + average_score: float = Field(description="Averaged score across evaluated samples.") + eval_status: EvalStatus = Field(description="Aggregated status from average and threshold.") + threshold: float = Field(description="Threshold used.") + status_counts: Optional[EvalStatusCounts] = None + + +class EvalCaseRunSummary(EvalBaseModel): + """Single run of an eval case.""" + + run_id: int = Field(description="1-based run id.") + final_eval_status: EvalStatus = Field(description="Final status for this run.") + error_message: Optional[str] = Field(default=None, description="Error if evaluation failed.") + metric_results: list[EvalMetricRunSummary] = Field( + default_factory=list, + description="Metric outcomes for this run.", + ) + + +class EvalSetRunSummary(EvalBaseModel): + """Summary of a single eval set run.""" + + run_id: int = Field(description="1-based run id.") + overall_status: EvalStatus = Field(description="Overall status for this run.") + case_status_counts: Optional[EvalStatusCounts] = None + metric_summaries: list[EvalMetricSummary] = Field( + default_factory=list, + description="Aggregated metric outcomes across cases in this run.", + ) + + +class EvalCaseResultSummary(EvalBaseModel): + """Summary of a single eval case across runs.""" + + eval_id: str = Field(description="Eval case id.") + overall_status: EvalStatus = Field(description="Aggregated status across runs.") + run_status_counts: Optional[EvalStatusCounts] = None + metric_summaries: list[EvalMetricSummary] = Field( + default_factory=list, + description="Per-metric average score and status across runs.", + ) + run_summaries: list[EvalCaseRunSummary] = Field( + default_factory=list, + description="Per-run summaries for this case.", + ) + + +class EvalSetResultSummary(EvalBaseModel): + """Multi-run summary for an eval set result.""" + + overall_status: EvalStatus = Field(description="Aggregated status across all cases and runs.") + num_runs: int = Field(description="Number of runs.") + run_status_counts: Optional[EvalStatusCounts] = None + run_summaries: list[EvalSetRunSummary] = Field(default_factory=list) + eval_case_summaries: list[EvalCaseResultSummary] = Field(default_factory=list) + + +class EvalSetResult(EvalBaseModel): + """Results for an entire evaluation set. + + Attributes: + eval_set_result_id: Unique identifier for this result + eval_set_result_name: Human-readable name + eval_set_id: The eval set that was evaluated + eval_case_results: Results for each case in the set + summary: Multi-run summary (when num_runs > 1 or multiple cases). + creation_timestamp: When this result was created + """ + + eval_set_result_id: str + eval_set_result_name: Optional[str] = None + eval_set_id: str + eval_case_results: list[EvalCaseResult] = Field(default_factory=list) + summary: Optional[EvalSetResultSummary] = None + creation_timestamp: float = 0.0 + + +class EvalSetAggregateResult(EvalBaseModel): + """Single eval set run result: per-case, per-run outcomes.""" + + eval_results_by_eval_id: dict[str, list[EvalCaseResult]] = Field(default_factory=dict) + num_runs: int = 1 + + +class EvaluateResult(EvalBaseModel): + """Aggregated evaluation result: one entry per eval set.""" + + results_by_eval_set_id: dict[str, EvalSetAggregateResult] = Field(default_factory=dict) diff --git a/trpc_agent_sdk/evaluation/_eval_service_base.py b/trpc_agent_sdk/evaluation/_eval_service_base.py new file mode 100644 index 000000000..c1db5e046 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_service_base.py @@ -0,0 +1,225 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Base evaluation service interface. + +""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from enum import Enum +from typing import AsyncGenerator +from typing import Optional + +from pydantic import Field + +from ._common import EvalBaseModel +from ._eval_case import Invocation +from ._eval_metrics import EvalMetric +from ._eval_result import EvalCaseResult + + +class EvaluateConfig(EvalBaseModel): + """Configuration for running evaluations. + + Attributes: + eval_metrics: List of metrics to evaluate + parallelism: Number of parallel evaluations to run + """ + + eval_metrics: list[EvalMetric] = Field(description="The list of metrics to be used in Eval.", ) + + parallelism: int = Field( + default=4, + description="""Number of parallel evaluations to run during an Eval. Few +factors to consider while changing this value: + +1) Your available quota with the model, especially for those metrics that use +a model as a judge. Models tend to enforce per-minute or per-second SLAs. Using +a larger value could result in the eval quickly consuming the quota. +""", + ) + + +class InferenceConfig(EvalBaseModel): + """Configuration for running inferences. + + Attributes: + labels: User-defined metadata labels + parallelism: Number of parallel inferences to run + """ + + labels: Optional[dict[str, str]] = Field( + default=None, + description="Labels with user-defined metadata to break down billed charges.", + ) + + parallelism: int = Field( + default=4, + description="""Number of parallel inferences to run during an Eval. Few +factors to consider while changing this value: + +1) Your available quota with the model. Models tend to enforce per-minute or +per-second SLAs. Using a larger value could result in the eval quickly consuming +the quota. + +2) The tools used by the Agent could also have their SLA. Using a larger value +could also overwhelm those tools.""", + ) + + +class InferenceRequest(EvalBaseModel): + """Request to perform inferences for eval cases. + + This is the set-level request: it identifies which eval set to run and how. + For each case, the actual app/session used at runtime may differ (e.g. from + case session_input or runner); see InferenceResult.app_name and case-level + callback args (e.g. session_app_name) for the effective app per case. + + Attributes: + app_name: Set-level app name (see Field description). + eval_set_id: ID of the eval set. + eval_case_ids: Optional list of specific eval case IDs. + inference_config: Configuration for inference. + """ + + app_name: str = Field(description="Set-level app name: used to load the eval set and as the default " + "for result tagging. The effective app for a single case may be overridden by " + "that case's session_input.app_name or by the runner's app_name.") + + eval_set_id: str = Field(description="Id of the eval set.") + + eval_case_ids: Optional[list[str]] = Field( + default=None, + description="""Id of the eval cases for which inferences need to be +generated. + +All the eval case ids should belong to the EvalSet. + +If the list of eval case ids are empty or not specified, then all the eval cases +in an eval set are evaluated. + """, + ) + + inference_config: InferenceConfig = Field(description="The config to use for inference.", ) + + +class InferenceStatus(Enum): + """Status of an inference operation.""" + + UNKNOWN = 0 + SUCCESS = 1 + FAILURE = 2 + + +class InferenceResult(EvalBaseModel): + """Results from inference a single eval case. + + Attributes: + app_name: Name of the application + eval_set_id: ID of the eval set + eval_case_id: ID of the eval case + inferences: List of invocations generated + session_id: Session ID used + status: Status of the inference + error_message: Error message if failed + """ + + app_name: str = Field(description="The name of the app to which the eval case belongs to.") + + eval_set_id: str = Field(description="Id of the eval set.") + + eval_case_id: str = Field(description="Id of the eval case for which inferences were generated.", ) + + inferences: Optional[list[Invocation]] = Field( + default=None, + description="Inferences obtained from the Agent for the eval case.", + ) + + session_id: Optional[str] = Field(default=None, description="Id of the inference session.") + + status: InferenceStatus = Field( + default=InferenceStatus.UNKNOWN, + description="Status of the inference.", + ) + + error_message: Optional[str] = Field( + default=None, + description="Error message if the inference failed.", + ) + + run_id: Optional[int] = Field( + default=None, + description="1-based run index when num_runs > 1.", + ) + + +class EvaluateRequest(EvalBaseModel): + """Request to evaluate inference results. + + Attributes: + inference_results: List of inference results to evaluate + evaluate_config: Configuration for evaluation + """ + + inference_results: list[InferenceResult] = Field(description="A list of inferences that need to be evaluated.", ) + + evaluate_config: EvaluateConfig = Field(description="The config to use for evaluations.", ) + + +class BaseEvalService(ABC): + """Abstract base class for evaluation services. + + This defines the interface for running evaluations in TRPC agents. + """ + + @abstractmethod + async def perform_inference( + self, + inference_request: InferenceRequest, + ) -> AsyncGenerator[InferenceResult, None]: + """Generate inferences for eval cases. + + Args: + inference_request: The request for generating inferences + + Yields: + InferenceResult for each eval case + """ + ... + + @abstractmethod + async def evaluate( + self, + evaluate_request: EvaluateRequest, + ) -> AsyncGenerator[EvalCaseResult, None]: + """Evaluate inference results. + + Args: + evaluate_request: The request to evaluate inferences + + Yields: + EvalCaseResult for each evaluated case + """ + ... diff --git a/trpc_agent_sdk/evaluation/_eval_session_service.py b/trpc_agent_sdk/evaluation/_eval_session_service.py new file mode 100644 index 000000000..d8c97b82b --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_session_service.py @@ -0,0 +1,99 @@ +# 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. +"""Eval-only SessionService wrapper: injects context_messages into session.events at create_session.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import Session + + +class EvalSessionService(BaseSessionService): + """Wraps a SessionService: on create_session, if context_messages were passed in, + prepends them to session.events.""" + + def __init__(self, inner: BaseSessionService, context_messages: Optional[list] = None): + super().__init__(summarizer_manager=getattr(inner, "summarizer_manager", None)) + self._inner = inner + self._context_messages = context_messages + + @override + async def create_session( + self, + *, + app_name: str, + user_id: str, + state: Optional[dict[str, Any]] = None, + session_id: Optional[str] = None, + agent_context: Optional[Any] = None, + ) -> Session: + context_messages, self._context_messages = self._context_messages, None + session = await self._inner.create_session( + app_name=app_name, + user_id=user_id, + state=state or {}, + session_id=session_id, + agent_context=agent_context, + ) + if context_messages: + user_messages = [] + for content in reversed(context_messages): + author = content.role or "user" + user_messages.append(Event(author=author, content=content)) + if user_messages: + user_messages.reverse() + session.insert_events(user_messages) + await self._inner.update_session(session) + return session + + @override + async def get_session( + self, + *, + app_name: str, + user_id: str, + session_id: str, + agent_context: Optional[Any] = None, + ): + return await self._inner.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + agent_context=agent_context, + ) + + @override + async def list_sessions(self, *, app_name: str, user_id: str): + return await self._inner.list_sessions(app_name=app_name, user_id=user_id) + + @override + async def delete_session(self, *, app_name: str, user_id: str, session_id: str) -> None: + return await self._inner.delete_session(app_name=app_name, user_id=user_id, session_id=session_id) + + @override + async def append_event(self, session: Session, event: Event) -> Event: + return await self._inner.append_event(session=session, event=event) + + @override + async def update_session(self, session: Session) -> None: + return await self._inner.update_session(session=session) + + @override + async def create_session_summary(self, session: Session, ctx: Any = None) -> None: + return await self._inner.create_session_summary(session=session, ctx=ctx) + + @override + async def get_session_summary(self, session: Session): + return await self._inner.get_session_summary(session=session) + + @override + async def close(self) -> None: + return await self._inner.close() diff --git a/trpc_agent_sdk/evaluation/_eval_set.py b/trpc_agent_sdk/evaluation/_eval_set.py new file mode 100644 index 000000000..12690977a --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_set.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 Apache-2.0. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation set data structure.""" + +from __future__ import annotations + +from typing import Optional + +from ._common import EvalBaseModel +from ._eval_case import EvalCase + + +class EvalSet(EvalBaseModel): + """A collection of evaluation test cases. + + Attributes: + eval_set_id: Unique identifier for this eval set + app_name: Optional default app name for session/result (used when case has no session_input.app_name) + name: Human-readable name + description: Description of what this eval set tests + eval_cases: List of test cases in this set + creation_timestamp: When this eval set was created + """ + + eval_set_id: str + """Unique identifier for the eval set.""" + + app_name: Optional[str] = None + """Default app name for this eval set (session/result). Case session_input.app_name overrides when present.""" + + name: Optional[str] = None + """Name of the dataset.""" + + description: Optional[str] = None + """Description of the dataset.""" + + eval_cases: list[EvalCase] + """List of eval cases in the dataset.""" + + creation_timestamp: float = 0.0 + """The time when this eval set was created.""" diff --git a/trpc_agent_sdk/evaluation/_eval_set_results_manager_base.py b/trpc_agent_sdk/evaluation/_eval_set_results_manager_base.py new file mode 100644 index 000000000..39d5e7661 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_set_results_manager_base.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 Apache-2.0. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation set results manager interface. + +""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod + +from ._eval_result import EvalCaseResult +from ._eval_result import EvalSetResult + + +class EvalSetResultsManager(ABC): + """An interface to manage Eval Set Results.""" + + @abstractmethod + def save_eval_set_result( + self, + app_name: str, + eval_set_id: str, + eval_case_results: list[EvalCaseResult], + ) -> None: + """Creates and saves a new EvalSetResult given eval_case_results. + + Args: + app_name: Name of the application + eval_set_id: ID of the eval set + eval_case_results: List of eval case results + """ + ... + + @abstractmethod + def get_eval_set_result(self, app_name: str, eval_set_result_id: str) -> EvalSetResult: + """Returns the EvalSetResult from app_name and eval_set_result_id. + + Args: + app_name: Name of the application + eval_set_result_id: ID of the eval set result + + Returns: + EvalSetResult for the given IDs + + Raises: + FileNotFoundError: If the EvalSetResult is not found. + """ + ... + + @abstractmethod + def list_eval_set_results(self, app_name: str) -> list[str]: + """Returns the eval result ids that belong to the given app_name. + + Args: + app_name: Name of the application + + Returns: + List of eval set result IDs + """ + ... diff --git a/trpc_agent_sdk/evaluation/_eval_set_results_manager_utils.py b/trpc_agent_sdk/evaluation/_eval_set_results_manager_utils.py new file mode 100644 index 000000000..27de5ce3b --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_set_results_manager_utils.py @@ -0,0 +1,258 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Build EvalSetResultSummary and EvalSetResult from eval case results.""" + +from __future__ import annotations + +import time +from collections import defaultdict + +from ._eval_metrics import EvalStatus +from ._eval_result import EvalCaseResult +from ._eval_result import EvalCaseResultSummary +from ._eval_result import EvalCaseRunSummary +from ._eval_result import EvalMetricResult +from ._eval_result import EvalMetricRunSummary +from ._eval_result import EvalMetricSummary +from ._eval_result import EvalSetResult +from ._eval_result import EvalSetResultSummary +from ._eval_result import EvalSetRunSummary +from ._eval_result import EvalStatusCounts + + +def _sanitize_eval_set_result_name(eval_set_result_name: str) -> str: + """Return name with '/' replaced by '_' for safe use in paths.""" + return eval_set_result_name.replace("/", "_") + + +def _add_status(counts: EvalStatusCounts, status: EvalStatus) -> None: + """Increment the corresponding count in counts for the given status.""" + if status == EvalStatus.PASSED: + counts.passed += 1 + elif status == EvalStatus.FAILED: + counts.failed += 1 + else: + counts.not_evaluated += 1 + + +def _overall_status_from_counts(counts: EvalStatusCounts) -> EvalStatus: + """Return FAILED if any failed, else PASSED if any passed, else NOT_EVALUATED.""" + if counts.failed > 0: + return EvalStatus.FAILED + if counts.passed > 0: + return EvalStatus.PASSED + return EvalStatus.NOT_EVALUATED + + +def _normalize_counts(counts: EvalStatusCounts) -> EvalStatusCounts | None: + """Return None if all counts are zero, else counts.""" + if counts.passed == 0 and counts.failed == 0 and counts.not_evaluated == 0: + return None + return counts + + +def _merge_metric_agg( + agg: dict[str, dict], + metric_results: list[EvalMetricResult], +) -> None: + """Merge metric_results into agg: per-metric threshold, count, score sum, status counts.""" + for m in metric_results: + if m is None: + continue + name = m.metric_name + if name not in agg: + agg[name] = { + "threshold": m.threshold or 0.0, + "evaluated_count": 0, + "score_sum": 0.0, + "status_counts": EvalStatusCounts(), + } + ent = agg[name] + _add_status(ent["status_counts"], m.eval_status) + if m.eval_status != EvalStatus.NOT_EVALUATED: + ent["evaluated_count"] += 1 + ent["score_sum"] += (m.score or 0.0) + + +def _build_metric_summaries(agg: dict[str, dict]) -> list[EvalMetricSummary]: + """Build EvalMetricSummary list from agg (average_score, eval_status, status_counts).""" + if not agg: + return [] + out: list[EvalMetricSummary] = [] + for name in sorted(agg.keys()): + ent = agg[name] + counts = ent["status_counts"] + avg_score = 0.0 + eval_status = EvalStatus.NOT_EVALUATED + if ent["evaluated_count"] > 0: + avg_score = ent["score_sum"] / ent["evaluated_count"] + thresh = ent["threshold"] + eval_status = (EvalStatus.PASSED if avg_score >= thresh else EvalStatus.FAILED) + out.append( + EvalMetricSummary( + metric_name=name, + average_score=avg_score, + eval_status=eval_status, + threshold=ent["threshold"], + status_counts=_normalize_counts(counts), + )) + return out + + +def _build_metric_run_summaries(metric_results: list[EvalMetricResult], ) -> list[EvalMetricRunSummary]: + """Build EvalMetricRunSummary list from metric_results, sorted by metric_name.""" + if not metric_results: + return [] + return [ + EvalMetricRunSummary( + metric_name=m.metric_name, + score=m.score or 0.0, + eval_status=m.eval_status, + threshold=m.threshold or 0.0, + ) for m in sorted(metric_results, key=lambda x: x.metric_name) if m is not None + ] + + +def _summarize_overall_from_metric_summaries( + metric_summaries: list[EvalMetricSummary], + has_run_error: bool, +) -> EvalStatus: + """Overall status from metric summaries; FAILED if any failed or has_run_error with no statuses.""" + statuses = [s.eval_status for s in metric_summaries if s is not None] + if not statuses: + return EvalStatus.FAILED if has_run_error else EvalStatus.NOT_EVALUATED + failed = any(s == EvalStatus.FAILED for s in statuses) + passed = any(s == EvalStatus.PASSED for s in statuses) + if failed: + return EvalStatus.FAILED + if passed: + return EvalStatus.PASSED + return EvalStatus.FAILED if has_run_error else EvalStatus.NOT_EVALUATED + + +def build_eval_set_result_summary( + eval_case_results: list[EvalCaseResult], + expected_num_runs: int | None = None, +) -> EvalSetResultSummary | None: + """Build EvalSetResultSummary from eval_case_results: run/case/metric structure and status counts. + Returns None if eval_case_results is empty.""" + if not eval_case_results: + return None + run_results: dict[int, list[EvalCaseResult]] = defaultdict(list) + for r in eval_case_results: + if r.run_id is not None: + run_results[r.run_id].append(r) + if not run_results: + return None + num_runs = expected_num_runs or max(run_results) + if num_runs < 1: + num_runs = 1 + run_ids = list(range(1, num_runs + 1)) + run_status_counts = EvalStatusCounts() + run_summaries_list: list[EvalSetRunSummary] = [] + for run_id in run_ids: + cases = run_results.get(run_id, []) + case_status_counts = EvalStatusCounts() + run_metric_agg: dict[str, dict] = {} + for c in cases: + _add_status(case_status_counts, c.final_eval_status) + _add_status(run_status_counts, c.final_eval_status) + _merge_metric_agg(run_metric_agg, c.overall_eval_metric_results) + run_status = _overall_status_from_counts(case_status_counts) + run_summaries_list.append( + EvalSetRunSummary( + run_id=run_id, + overall_status=run_status, + case_status_counts=_normalize_counts(case_status_counts), + metric_summaries=_build_metric_summaries(run_metric_agg), + )) + overall_status = _overall_status_from_counts(run_status_counts) + eval_ids = sorted({c.eval_id for c in eval_case_results}) + eval_case_summaries_list: list[EvalCaseResultSummary] = [] + for eval_id in eval_ids: + case_list = [c for c in eval_case_results if c.eval_id == eval_id] + case_counts = EvalStatusCounts() + case_metric_agg: dict[str, dict] = {} + has_run_error = any(c.error_message for c in case_list if c.error_message) + run_summaries_for_case: list[EvalCaseRunSummary] = [] + for c in case_list: + _add_status(case_counts, c.final_eval_status) + _merge_metric_agg(case_metric_agg, c.overall_eval_metric_results) + if c.run_id is not None: + run_summaries_for_case.append( + EvalCaseRunSummary( + run_id=c.run_id, + final_eval_status=c.final_eval_status, + error_message=c.error_message, + metric_results=_build_metric_run_summaries(c.overall_eval_metric_results), + )) + run_summaries_for_case.sort(key=lambda x: x.run_id) + metric_summaries = _build_metric_summaries(case_metric_agg) + case_overall = _summarize_overall_from_metric_summaries(metric_summaries, has_run_error) + eval_case_summaries_list.append( + EvalCaseResultSummary( + eval_id=eval_id, + overall_status=case_overall, + run_status_counts=_normalize_counts(case_counts), + metric_summaries=metric_summaries, + run_summaries=run_summaries_for_case, + )) + return EvalSetResultSummary( + overall_status=overall_status, + num_runs=num_runs, + run_status_counts=_normalize_counts(run_status_counts), + run_summaries=run_summaries_list, + eval_case_summaries=eval_case_summaries_list, + ) + + +def create_eval_set_result( + app_name: str, + eval_set_id: str, + eval_case_results: list[EvalCaseResult], + expected_num_runs: int | None = None, +) -> EvalSetResult: + """Create EvalSetResult from eval_case_results; summary built when multiple runs or cases. + + Args: + app_name: Application name. + eval_set_id: Eval set id. + eval_case_results: Per-case results (all runs). + expected_num_runs: Run count for summary; inferred from results if omitted. + + Returns: + EvalSetResult with id, name, results, summary, timestamp. + """ + timestamp = time.time() + eval_set_result_id = f"{app_name}_{eval_set_id}_{timestamp}" + eval_set_result_name = _sanitize_eval_set_result_name(eval_set_result_id) + summary = build_eval_set_result_summary(eval_case_results, expected_num_runs) + eval_set_result = EvalSetResult( + eval_set_result_id=eval_set_result_id, + eval_set_result_name=eval_set_result_name, + eval_set_id=eval_set_id, + eval_case_results=eval_case_results, + summary=summary, + creation_timestamp=timestamp, + ) + return eval_set_result diff --git a/trpc_agent_sdk/evaluation/_eval_sets_manager_base.py b/trpc_agent_sdk/evaluation/_eval_sets_manager_base.py new file mode 100644 index 000000000..899569cf3 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_sets_manager_base.py @@ -0,0 +1,126 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluation sets manager interface. + +""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import Optional + +from ._eval_case import EvalCase +from ._eval_set import EvalSet + + +class EvalSetsManager(ABC): + """Abstract interface for managing evaluation sets. + + This provides CRUD operations for evaluation sets and their cases. + """ + + @abstractmethod + def get_eval_set(self, app_name: str, eval_set_id: str) -> Optional[EvalSet]: + """Get an eval set by ID. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + + Returns: + EvalSet if found, None otherwise + """ + ... + + @abstractmethod + def create_eval_set(self, app_name: str, eval_set_id: str) -> EvalSet: + """Create a new eval set. + + Args: + app_name: Application name + eval_set_id: ID for the new eval set + + Returns: + The created EvalSet + """ + ... + + @abstractmethod + def list_eval_sets(self, app_name: str) -> list[str]: + """List all eval set IDs for an application. + + Args: + app_name: Application name + + Returns: + List of eval set IDs + """ + ... + + @abstractmethod + def get_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str) -> Optional[EvalCase]: + """Get a specific eval case. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case_id: ID of the eval case + + Returns: + EvalCase if found, None otherwise + """ + ... + + @abstractmethod + def add_eval_case(self, app_name: str, eval_set_id: str, eval_case: EvalCase): + """Add an eval case to an eval set. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case: The eval case to add + """ + ... + + @abstractmethod + def update_eval_case(self, app_name: str, eval_set_id: str, updated_eval_case: EvalCase): + """Update an existing eval case. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + updated_eval_case: The updated eval case + """ + ... + + @abstractmethod + def delete_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str): + """Delete an eval case from an eval set. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case_id: ID of the eval case to delete + """ + ... diff --git a/trpc_agent_sdk/evaluation/_eval_sets_manager_utils.py b/trpc_agent_sdk/evaluation/_eval_sets_manager_utils.py new file mode 100644 index 000000000..166978410 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_eval_sets_manager_utils.py @@ -0,0 +1,102 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Utility functions for eval sets manager.""" + +from __future__ import annotations + +import logging +from typing import Optional + +from ._eval_case import EvalCase +from ._eval_set import EvalSet +from ._eval_sets_manager_base import EvalSetsManager + +logger = logging.getLogger(__name__) + + +class NotFoundError(Exception): + """Raised when a resource is not found.""" + pass + + +def get_eval_set_from_app_and_id(eval_sets_manager: EvalSetsManager, app_name: str, eval_set_id: str) -> EvalSet: + """Returns an EvalSet if found; otherwise, raises NotFoundError.""" + eval_set = eval_sets_manager.get_eval_set(app_name, eval_set_id) + if not eval_set: + raise NotFoundError(f"Eval set `{eval_set_id}` not found.") + return eval_set + + +def get_eval_case_from_eval_set(eval_set: EvalSet, eval_case_id: str) -> Optional[EvalCase]: + """Returns an EvalCase if found; otherwise, None.""" + eval_case_to_find = None + + # Look up the eval case by eval_case_id + for eval_case in eval_set.eval_cases: + if eval_case.eval_id == eval_case_id: + eval_case_to_find = eval_case + break + + return eval_case_to_find + + +def add_eval_case_to_eval_set(eval_set: EvalSet, eval_case: EvalCase) -> EvalSet: + """Adds an eval case to an eval set and returns the updated eval set.""" + eval_case_id = eval_case.eval_id + + if [x for x in eval_set.eval_cases if x.eval_id == eval_case_id]: + raise ValueError(f"Eval id `{eval_case_id}` already exists in `{eval_set.eval_set_id}`" + " eval set.", ) + + eval_set.eval_cases.append(eval_case) + return eval_set + + +def update_eval_case_in_eval_set(eval_set: EvalSet, updated_eval_case: EvalCase) -> EvalSet: + """Updates an eval case in an eval set and returns the updated eval set.""" + # Find the eval case to be updated. + eval_case_id = updated_eval_case.eval_id + eval_case_to_update = get_eval_case_from_eval_set(eval_set, eval_case_id) + + if not eval_case_to_update: + raise NotFoundError(f"Eval case `{eval_case_id}` not found in eval set" + f" `{eval_set.eval_set_id}`.") + + # Remove the existing eval case and add the updated eval case. + eval_set.eval_cases.remove(eval_case_to_update) + eval_set.eval_cases.append(updated_eval_case) + return eval_set + + +def delete_eval_case_from_eval_set(eval_set: EvalSet, eval_case_id: str) -> EvalSet: + """Deletes an eval case from an eval set and returns the updated eval set.""" + # Find the eval case to be deleted. + eval_case_to_delete = get_eval_case_from_eval_set(eval_set, eval_case_id) + + if not eval_case_to_delete: + raise NotFoundError(f"Eval case `{eval_case_id}` not found in eval set" + f" `{eval_set.eval_set_id}`.") + + # Remove the eval case from the eval set. + eval_set.eval_cases.remove(eval_case_to_delete) + return eval_set diff --git a/trpc_agent_sdk/evaluation/_evaluator_base.py b/trpc_agent_sdk/evaluation/_evaluator_base.py new file mode 100644 index 000000000..98e4f898f --- /dev/null +++ b/trpc_agent_sdk/evaluation/_evaluator_base.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 Apache-2.0. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Base evaluator interface.""" + +from __future__ import annotations + +from abc import ABC +from abc import abstractmethod +from typing import ClassVar +from typing import Optional + +from ._eval_case import Invocation +from ._eval_result import EvaluationResult + + +class Evaluator(ABC): + """Base class for all evaluators. + + An evaluator implements a specific evaluation metric, taking actual + and expected invocations and computing a score. + """ + + requires_reference: ClassVar[bool] = True + """Whether this metric requires expected_invocations with non-placeholder + `final_response` / `intermediate_data` fields (a "reference answer"). + + Set to False for reference-free metrics (e.g. rubric-based LLM judges that + evaluate actual output against a rubric, not against a reference answer). + + Checked by `LocalEvalService._validate_metric_compat` at evaluate() startup + to fail-fast on incompatible (eval_case, metric) combinations. Defaults to + True — safer for new evaluators; opt into False explicitly when adding a + reference-free metric. + + See Bug 3.1 of + docs/superpowers/specs/2026-05-06-evaluator-trace-metric-strict-compat-design.md. + """ + + @abstractmethod + def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + """Evaluate invocations and return results. + + Args: + actual_invocations: Invocations generated by the agent + expected_invocations: Expected/reference invocations + + Returns: + Evaluation result with scores and status + """ + ... diff --git a/trpc_agent_sdk/evaluation/_evaluator_registry.py b/trpc_agent_sdk/evaluation/_evaluator_registry.py new file mode 100644 index 000000000..aa2c67a8b --- /dev/null +++ b/trpc_agent_sdk/evaluation/_evaluator_registry.py @@ -0,0 +1,121 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Evaluator registry for managing available metrics.""" + +from __future__ import annotations + +from typing import Callable +from typing import Type + +from ._final_response_evaluator import FinalResponseEvaluator +from ._llm_evaluator import LLMFinalResponseEvaluator +from ._llm_evaluator import LLMRubricKnowledgeRecallEvaluator +from ._llm_evaluator import LLMRubricResponseEvaluator +from ._rouge_evaluator import RougeEvaluator +from ._trajectory_evaluator import TrajectoryEvaluator +from ._eval_metrics import EvalMetric +from ._eval_metrics import PrebuiltMetrics +from ._evaluator_base import Evaluator + + +class EvaluatorRegistry: + """Maps metric names to evaluator classes; provides get_evaluator(eval_metric). + + To customize the Compare rule in code (compare cannot be set from JSON), + use set_criterion_compare(metric_name, callable) before get_evaluator is used. + """ + + def __init__(self): + self._registry: dict[str, Type[Evaluator]] = {} + self._criterion_compares: dict[str, Callable[..., bool]] = {} + + # Register built-in evaluators + self.register(PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value, TrajectoryEvaluator) + self.register(PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value, FinalResponseEvaluator) + self.register(PrebuiltMetrics.RESPONSE_MATCH_SCORE.value, RougeEvaluator) + self.register(PrebuiltMetrics.LLM_FINAL_RESPONSE.value, LLMFinalResponseEvaluator) + self.register(PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, LLMRubricResponseEvaluator) + self.register(PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, LLMRubricKnowledgeRecallEvaluator) + + def register(self, metric_name: str, evaluator_class: Type[Evaluator]) -> None: + self._registry[metric_name] = evaluator_class + + def list_registered(self) -> list[str]: + """Return sorted names of all registered metrics.""" + return sorted(self._registry.keys()) + + def set_criterion_compare( + self, + metric_name: str, + compare: Callable[..., bool], + ) -> None: + """Set a custom compare for a criterion-based metric (code only; not from JSON). + + For final_response_avg_score: compare(actual, expected) -> bool; + actual/expected are final response content (Content-like or str). + For tool_trajectory_avg_score: compare(actual_tool_calls, expected_tool_calls) -> bool; + each is a list of tool calls (e.g. FunctionCall with .name, .args). + + Args: + metric_name: e.g. final_response_avg_score or tool_trajectory_avg_score. + compare: Callable that returns True when the pair is considered a match. + """ + self._criterion_compares[metric_name] = compare + + def get_evaluator(self, eval_metric: EvalMetric) -> Evaluator: + """Return evaluator instance for the given metric. + + Raises if metric not registered. + """ + if eval_metric.metric_name not in self._registry: + raise ValueError(f"No evaluator registered for metric: {eval_metric.metric_name}. " + f"Available metrics: {list(self._registry.keys())}") + + evaluator_class = self._registry[eval_metric.metric_name] + evaluator = evaluator_class(eval_metric=eval_metric) + + compare_fn = self._criterion_compares.get(eval_metric.metric_name) + if compare_fn is not None: + if hasattr(evaluator, "_criterion") and getattr(evaluator, "_criterion") is not None: + evaluator._criterion.compare = compare_fn + if hasattr(evaluator, "_trajectory_criterion") and getattr(evaluator, "_trajectory_criterion") is not None: + evaluator._trajectory_criterion.compare = compare_fn + + return evaluator + + def get_evaluator_class(self, eval_metric: EvalMetric) -> Type[Evaluator]: + """Return evaluator CLASS (not instance) for inspecting class attributes. + + Used by LocalEvalService._validate_metric_compat to look up the + `requires_reference` ClassVar without instantiating the evaluator + (which may have side effects like loading rouge-score or creating an + LLM judge). Raises if metric not registered. + """ + if eval_metric.metric_name not in self._registry: + raise ValueError(f"No evaluator registered for metric: {eval_metric.metric_name}. " + f"Available metrics: {list(self._registry.keys())}") + return self._registry[eval_metric.metric_name] + + +# Global default registry +EVALUATOR_REGISTRY = EvaluatorRegistry() diff --git a/trpc_agent_sdk/evaluation/_final_response_evaluator.py b/trpc_agent_sdk/evaluation/_final_response_evaluator.py new file mode 100644 index 000000000..9450211f1 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_final_response_evaluator.py @@ -0,0 +1,95 @@ +# 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. +"""Final response evaluator (criterion-based).""" + +from __future__ import annotations + +import statistics +from typing import Any +from typing import Optional +from typing_extensions import override + +from ._criterion_registry import CRITERION_REGISTRY +from ._eval_case import Invocation +from ._eval_criterion import FinalResponseCriterion +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_metrics import Interval +from ._eval_metrics import MetricInfo +from ._eval_metrics import MetricValueInfo +from ._eval_metrics import PrebuiltMetrics +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._evaluator_base import Evaluator + + +class FinalResponseEvaluator(Evaluator): + """Compares final response per invocation. + + Uses FinalResponseCriterion (text/json) when criterion.finalResponse set; + else exact text. Score 1.0 or 0.0 per invocation, overall = mean. + """ + + requires_reference = True + + def __init__( + self, + threshold: Optional[float] = None, + eval_metric: Optional[EvalMetric] = None, + ): + if threshold is not None and eval_metric: + raise ValueError("Either eval_metric should be specified or threshold should be specified.") + if eval_metric: + threshold = eval_metric.threshold + + self._threshold = threshold + self._criterion: Optional[Any] = None + if eval_metric and eval_metric.criterion: + self._criterion = CRITERION_REGISTRY.build(eval_metric.criterion, metric_key=eval_metric.metric_name) + if self._criterion is None: + self._criterion = FinalResponseCriterion.from_dict({"text": {"match": "exact"}}) + + @staticmethod + def get_metric_info() -> MetricInfo: + return MetricInfo( + metric_name=PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value, + description=("Binary match of final response per invocation using " + "FinalResponseCriterion (text and/or json). Score 1.0 or 0.0."), + metric_value_info=MetricValueInfo(interval=Interval(min_value=0.0, max_value=1.0)), + ) + + @override + def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + if expected_invocations is None: + raise ValueError("expected_invocations is required for final_response_avg_score") + + per_invocation_results = [] + for actual, expected in zip(actual_invocations, expected_invocations): + match = self._criterion.matches(actual.final_response, expected.final_response) + score = 1.0 if match else 0.0 + per_invocation_results.append( + PerInvocationResult( + actual_invocation=actual, + expected_invocation=expected, + score=score, + eval_status=self._get_eval_status(score), + )) + + if not per_invocation_results: + return EvaluationResult() + overall_score = statistics.mean([r.score for r in per_invocation_results]) + return EvaluationResult( + overall_score=overall_score, + overall_eval_status=self._get_eval_status(overall_score), + per_invocation_results=per_invocation_results, + ) + + def _get_eval_status(self, score: float) -> EvalStatus: + return EvalStatus.PASSED if score >= self._threshold else EvalStatus.FAILED diff --git a/trpc_agent_sdk/evaluation/_in_memory_eval_sets_manager.py b/trpc_agent_sdk/evaluation/_in_memory_eval_sets_manager.py new file mode 100644 index 000000000..7b900cf99 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_in_memory_eval_sets_manager.py @@ -0,0 +1,221 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""In-memory evaluation sets manager. + +This module provides an in-memory implementation of EvalSetsManager using +dictionaries. You can use this class: +1) As a part of your testcase. +2) For cases where other implementations of EvalSetsManager are too expensive to use. +""" + +from __future__ import annotations + +import time +from typing import Optional +from typing_extensions import override + +from ._eval_case import EvalCase +from ._eval_set import EvalSet +from ._eval_sets_manager_base import EvalSetsManager + + +class InMemoryEvalSetsManager(EvalSetsManager): + """In-memory implementation of evaluation sets manager using dictionaries. + + This class uses dual-layer indexing for efficient O(1) eval case lookups. + You can use this class: + 1) As a part of your testcase. + 2) For cases where other implementations of EvalSetsManager are too expensive to use. + """ + + def __init__(self): + """Initialize the in-memory eval sets manager.""" + # {app_name: {eval_set_id: EvalSet}} + self._eval_sets: dict[str, dict[str, EvalSet]] = {} + # {app_name: {eval_set_id: {eval_case_id: EvalCase}}} + self._eval_cases: dict[str, dict[str, dict[str, EvalCase]]] = {} + + def _ensure_app_exists(self, app_name: str): + """Ensure the app exists in storage.""" + if app_name not in self._eval_sets: + self._eval_sets[app_name] = {} + self._eval_cases[app_name] = {} + + @override + def get_eval_set(self, app_name: str, eval_set_id: str) -> Optional[EvalSet]: + """Get an eval set by ID. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + + Returns: + EvalSet if found, None otherwise + """ + self._ensure_app_exists(app_name) + return self._eval_sets[app_name].get(eval_set_id, None) + + @override + def create_eval_set(self, app_name: str, eval_set_id: str) -> EvalSet: + """Create a new eval set. + + Args: + app_name: Application name + eval_set_id: ID for the new eval set + + Returns: + The created EvalSet + + Raises: + ValueError: If eval set already exists + """ + self._ensure_app_exists(app_name) + + if eval_set_id in self._eval_sets[app_name]: + raise ValueError(f"EvalSet {eval_set_id} already exists for app {app_name}.") + + new_eval_set = EvalSet( + eval_set_id=eval_set_id, + eval_cases=[], + creation_timestamp=time.time(), + ) + self._eval_sets[app_name][eval_set_id] = new_eval_set + self._eval_cases[app_name][eval_set_id] = {} + return new_eval_set + + @override + def list_eval_sets(self, app_name: str) -> list[str]: + """List all eval set IDs for an application. + + Args: + app_name: Application name + + Returns: + List of eval set IDs + """ + if app_name not in self._eval_sets: + return [] + + return list(self._eval_sets[app_name].keys()) + + @override + def get_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str) -> Optional[EvalCase]: + """Get a specific eval case. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case_id: ID of the eval case + + Returns: + EvalCase if found, None otherwise + """ + if app_name not in self._eval_cases: + return None + if eval_set_id not in self._eval_cases[app_name]: + return None + return self._eval_cases[app_name][eval_set_id].get(eval_case_id) + + @override + def add_eval_case(self, app_name: str, eval_set_id: str, eval_case: EvalCase): + """Add an eval case to an eval set. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case: The eval case to add + + Raises: + ValueError: If eval set not found or eval case already exists + """ + self._ensure_app_exists(app_name) + + if eval_set_id not in self._eval_sets[app_name]: + raise ValueError(f"EvalSet {eval_set_id} not found for app {app_name}.") + + if eval_case.eval_id in self._eval_cases[app_name][eval_set_id]: + raise ValueError(f"EvalCase {eval_case.eval_id} already exists in EvalSet" + f" {eval_set_id} for app {app_name}.") + + self._eval_cases[app_name][eval_set_id][eval_case.eval_id] = eval_case + # Also update the list in the EvalSet object + self._eval_sets[app_name][eval_set_id].eval_cases.append(eval_case) + + @override + def update_eval_case(self, app_name: str, eval_set_id: str, updated_eval_case: EvalCase): + """Update an existing eval case. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + updated_eval_case: The updated eval case + + Raises: + ValueError: If eval set or eval case not found + """ + self._ensure_app_exists(app_name) + + if eval_set_id not in self._eval_sets[app_name]: + raise ValueError(f"EvalSet {eval_set_id} not found for app {app_name}.") + + if updated_eval_case.eval_id not in self._eval_cases[app_name][eval_set_id]: + raise ValueError(f"EvalCase {updated_eval_case.eval_id} not found in EvalSet" + f" {eval_set_id} for app {app_name}.") + + # Full replace in index + self._eval_cases[app_name][eval_set_id][updated_eval_case.eval_id] = updated_eval_case + + # Update the list in the EvalSet object + eval_set = self._eval_sets[app_name][eval_set_id] + for i, case in enumerate(eval_set.eval_cases): + if case.eval_id == updated_eval_case.eval_id: + eval_set.eval_cases[i] = updated_eval_case + break + + @override + def delete_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str): + """Delete an eval case from an eval set. + + Args: + app_name: Application name + eval_set_id: ID of the eval set + eval_case_id: ID of the eval case to delete + + Raises: + ValueError: If eval set or eval case not found + """ + self._ensure_app_exists(app_name) + + if eval_set_id not in self._eval_sets[app_name]: + raise ValueError(f"EvalSet {eval_set_id} not found for app {app_name}.") + + if eval_case_id not in self._eval_cases[app_name][eval_set_id]: + raise ValueError(f"EvalCase {eval_case_id} not found in EvalSet {eval_set_id}" + f" for app {app_name}.") + + # Delete from index + del self._eval_cases[app_name][eval_set_id][eval_case_id] + + # Remove from the list in the EvalSet object + eval_set = self._eval_sets[app_name][eval_set_id] + eval_set.eval_cases = [case for case in eval_set.eval_cases if case.eval_id != eval_case_id] diff --git a/trpc_agent_sdk/evaluation/_llm_criterion.py b/trpc_agent_sdk/evaluation/_llm_criterion.py new file mode 100644 index 000000000..7294c2f36 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_llm_criterion.py @@ -0,0 +1,248 @@ +# 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. +"""Criterion types and helpers for LLM judge metrics (EvalMetric.criterion).""" + +from __future__ import annotations + +import copy +from typing import Any +from typing import Optional + +from pydantic import Field +from pydantic import model_serializer +from pydantic import model_validator + +from ._common import EvalBaseModel + +DEFAULT_NUM_SAMPLES = 1 +DEFAULT_MODELS_AGGREGATOR = "all_pass" +DEFAULT_PARALLEL = True +BUILT_IN_MODELS_AGGREGATORS = frozenset({ + "all_pass", + "any_pass", + "majority_pass", + "avg", + "weighted_avg", + "weighted_majority", +}) +WEIGHTED_MODELS_AGGREGATORS = frozenset({"weighted_avg", "weighted_majority"}) + + +def sanitize_criterion_for_export(criterion: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + """Return a copy of criterion with api_key removed from nested llmJudge/judgeModel. + Use when exporting to JSON to avoid writing secrets.""" + if not criterion or not isinstance(criterion, dict): + return criterion + out = copy.deepcopy(criterion) + for key in ("llmJudge", "llm_judge"): + block = out.get(key) + if not isinstance(block, dict): + continue + for jkey in ("judgeModel", "judge_model"): + judge = block.get(jkey) + if isinstance(judge, dict): + judge = {k: v for k, v in judge.items() if k not in ("api_key", "apiKey")} + block = {**block, jkey: judge} + out = {**out, key: block} + return out + + +DEFAULT_KNOWLEDGE_TOOL_NAMES = ["knowledge_search"] + + +class RubricContent(EvalBaseModel): + """Content for one rubric item, shown to the judge model.""" + + text: str = Field(default="", description="Text shown to the judge.") + + +class Rubric(EvalBaseModel): + """One rubric item for LLM evaluation (id, content, description, type).""" + + id: str = Field(default="", description="Unique id for this rubric item.") + content: Optional[RubricContent] = Field( + default=None, + description="Content shown to the judge model.", + ) + description: str = Field(default="", description="Short human-readable description.") + type: str = Field(default="", description="Rubric type label (e.g. FINAL_RESPONSE_QUALITY).") + + +class JudgeModelOptions(EvalBaseModel): + """Judge model config: provider, model, num_samples, generation_config. + api_key is omitted when serialized.""" + + provider_name: str = Field(default="", description="LLM provider name.") + model_name: str = Field(default="", description="Judge model name.") + variant: str = Field(default="", description="OpenAI-compatible variant when provider is openai.") + base_url: Optional[str] = Field(default=None, description="Optional custom endpoint.") + api_key: str = Field(default="", description="API key for the judge provider.") + extra_fields: Optional[dict[str, Any]] = Field( + default=None, + description="Extra provider-specific fields.", + ) + num_samples: Optional[int] = Field( + default=None, + description="Number of judge samples per invocation; default DEFAULT_NUM_SAMPLES.", + ) + generation_config: Optional[dict[str, Any]] = Field( + default=None, + description="Generation params: max_tokens, temperature, stream, etc.", + ) + weight: float = Field( + default=1.0, + description="Weight for weighted_* models_aggregator; ignored otherwise.", + ) + think: Optional[bool] = Field( + default=None, + description=("Toggle judge thinking mode. None (default): no change; " + "False: disable thinking via both ThinkingConfig(include_thoughts=False, " + "thinking_budget=0) and extra_body.chat_template_kwargs.enable_thinking=False; " + "True: enable thinking with automatic budget."), + ) + + def get_num_samples(self) -> int: + """Return configured num_samples or DEFAULT_NUM_SAMPLES.""" + if self.num_samples is not None and self.num_samples > 0: + return self.num_samples + return DEFAULT_NUM_SAMPLES + + @model_serializer(mode="wrap") + def _serialize(self, serializer: Any) -> dict[str, Any]: + """Omit api_key from serialization.""" + data = serializer(self) + if isinstance(data, dict): + data = {k: v for k, v in data.items() if k not in ("api_key", "apiKey")} + return data + + +class RubricScore(EvalBaseModel): + """One rubric item's score from the judge (id, reason, score).""" + + id: str = Field(default="", description="Rubric item id.") + reason: str = Field(default="", description="Reason for this score.") + score: float = Field(default=0.0, description="Numeric score for this rubric item.") + + +class ScoreResult(EvalBaseModel): + """Result of one judge call: score, reason, and optional per-rubric rubric_scores.""" + + score: float = Field(default=0.0, description="Numeric score.") + reason: str = Field(default="", description="Reason from judge.") + rubric_scores: list[RubricScore] = Field( + default_factory=list, + description="Per-rubric scores for rubric-based metrics.", + ) + + +class LLMJudgeCriterion(EvalBaseModel): + """Criterion for LLM judge metrics: judge_model, rubrics, knowledge_tool_names. + Built from EvalMetric.criterion.llmJudge. Use LLM evaluators to run the judge.""" + + judge_model: Optional[JudgeModelOptions] = Field( + default=None, + description="Judge model options (required for all LLM judge metrics).", + ) + judge_models: Optional[list[JudgeModelOptions]] = Field( + default=None, + description=("Multi-model judge list. Mutually exclusive with judge_model. " + "Cross-model results are combined by models_aggregator."), + ) + models_aggregator: str = Field( + default=DEFAULT_MODELS_AGGREGATOR, + description=("Cross-model aggregation strategy. Built-in: all_pass | any_pass | " + "majority_pass | avg | weighted_avg | weighted_majority. " + "Custom names must be registered via " + "LLM_EVALUATOR_REGISTRY.register_models_aggregator before LLMJudge construction."), + ) + parallel: bool = Field( + default=DEFAULT_PARALLEL, + description="Run multiple judge models concurrently via asyncio.gather (default True).", + ) + rubrics: list[Rubric] = Field( + default_factory=list, + description="Rubric items for rubric-based metrics.", + ) + knowledge_tool_names: Optional[list[str]] = Field( + default=None, + description=("Tool names treated as knowledge retrieval for llm_rubric_knowledge_recall. " + "If unset, DEFAULT_KNOWLEDGE_TOOL_NAMES is used."), + ) + + def get_num_samples(self) -> int: + """Return judge_model num_samples or DEFAULT_NUM_SAMPLES.""" + if self.judge_model is not None: + return self.judge_model.get_num_samples() + return DEFAULT_NUM_SAMPLES + + def get_knowledge_tool_names(self) -> list[str]: + """Return knowledge tool names; uses DEFAULT_KNOWLEDGE_TOOL_NAMES when unset.""" + if self.knowledge_tool_names: + return list(self.knowledge_tool_names) + return list(DEFAULT_KNOWLEDGE_TOOL_NAMES) + + @model_validator(mode="after") + def _validate_multi_model_fields(self) -> "LLMJudgeCriterion": + """Validate judge_model/judge_models exclusivity, weights, and aggregator name shape. + + Registry-registered aggregator names are not validated here; only built-in + names are rejected at LLMJudge construction time when registry lookup misses. + """ + if self.judge_model is not None and self.judge_models is not None: + raise ValueError("judge_model and judge_models are mutually exclusive; set only one") + if self.judge_models is not None and len(self.judge_models) == 0: + raise ValueError("judge_models must not be empty when set") + if not isinstance(self.models_aggregator, str) or not self.models_aggregator: + raise ValueError("models_aggregator must be a non-empty string") + models = self.get_judge_models() + for m in models: + if m.weight < 0: + raise ValueError(f"judge model weight must not be negative: model_name={m.model_name!r} " + f"weight={m.weight}") + if self.models_aggregator in WEIGHTED_MODELS_AGGREGATORS and models: + total = sum(m.weight for m in models) + if total <= 0: + raise ValueError(f"models_aggregator={self.models_aggregator!r} requires sum of weights > 0; " + f"got total weight {total}") + return self + + def get_judge_models(self) -> list[JudgeModelOptions]: + """Return effective list of judge model options. + + - judge_models set -> returned as-is. + - only legacy judge_model set -> returned as 1-element list. + - neither set -> []. Caller (LLMJudge) decides whether to error. + """ + if self.judge_models is not None: + return list(self.judge_models) + if self.judge_model is not None: + return [self.judge_model] + return [] + + @classmethod + def from_dict(cls, d: dict | None) -> Optional["LLMJudgeCriterion"]: + """Build from config dict (judgeModel, rubrics, knowledge_tool_names; camelCase or snake_case). + Returns None if d is None or validation fails.""" + if not d or not isinstance(d, dict): + return None + try: + return cls.model_validate(d) + except Exception: + return None + + +def get_llm_criterion_from_metric(eval_metric: Any) -> Optional[LLMJudgeCriterion]: + """Return LLMJudgeCriterion from EvalMetric.criterion when llmJudge config is present. + For criterion registry use CRITERION_REGISTRY.build(criterion, metric_key=...) instead.""" + if eval_metric is None or not getattr(eval_metric, "criterion", None): + return None + c = getattr(eval_metric, "criterion") + if not isinstance(c, dict): + return None + llm_raw = c.get("llmJudge") or c.get("llm_judge") + if not isinstance(llm_raw, dict): + return None + return LLMJudgeCriterion.from_dict(llm_raw) diff --git a/trpc_agent_sdk/evaluation/_llm_evaluator.py b/trpc_agent_sdk/evaluation/_llm_evaluator.py new file mode 100644 index 000000000..189b16f6e --- /dev/null +++ b/trpc_agent_sdk/evaluation/_llm_evaluator.py @@ -0,0 +1,253 @@ +# 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. +"""LLM-backed evaluators: delegate to LLMJudge; registry for pluggable judge protocols.""" + +from __future__ import annotations + +from typing import Any +from typing import Callable +from typing import List +from typing import Optional +from typing_extensions import override + +from ._eval_case import Invocation +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_metrics import PrebuiltMetrics +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._evaluator_base import Evaluator +from ._llm_criterion import LLMJudgeCriterion +from ._llm_criterion import ScoreResult +from ._llm_judge import InvocationsAggregator +from ._llm_judge import LLMJudge +from ._llm_judge import MessagesConstructor +from ._llm_judge import ModelsAggregator +from ._llm_judge import ResponseScorer +from ._llm_judge import SamplesAggregator + +# Metric names that use LLM judge; registry applies to these. +LLM_METRIC_NAMES = frozenset({ + PrebuiltMetrics.LLM_FINAL_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, +}) + +# Type aliases for plain functions that users register. +MessagesConstructorFn = Callable[[list[Invocation], Optional[list[Invocation]], LLMJudgeCriterion, str], str] +ResponseScorerFn = Callable[[str, str], ScoreResult] +SamplesAggregatorFn = Callable[[list[ScoreResult], float], ScoreResult] +InvocationsAggregatorFn = Callable[[list[PerInvocationResult], float], tuple[Optional[float], EvalStatus]] +ModelsAggregatorFn = Callable[[list[ScoreResult], float, list[float]], ScoreResult] + + +class _MessagesConstructorAdapter: + """Adapts a plain function to MessagesConstructor.""" + + def __init__(self, fn: MessagesConstructorFn) -> None: + self._fn = fn + + def format_user_message(self, actuals, expecteds, criterion, metric_name): + return self._fn(actuals, expecteds, criterion, metric_name) + + +class _ResponseScorerAdapter: + """Adapts a plain function to ResponseScorer.""" + + def __init__(self, fn: ResponseScorerFn) -> None: + self._fn = fn + + def parse_response(self, response_text, metric_name): + return self._fn(response_text, metric_name) + + +class _SamplesAggregatorAdapter: + """Adapts a plain function to SamplesAggregator.""" + + def __init__(self, fn: SamplesAggregatorFn) -> None: + self._fn = fn + + def aggregate_samples(self, samples, threshold): + return self._fn(samples, threshold) + + +class _InvocationsAggregatorAdapter: + """Adapts a plain function to InvocationsAggregator.""" + + def __init__(self, fn: InvocationsAggregatorFn) -> None: + self._fn = fn + + def aggregate_invocations(self, results, threshold): + return self._fn(results, threshold) + + +class _ModelsAggregatorAdapter: + """Adapts a plain function to ModelsAggregator.""" + + def __init__(self, fn: ModelsAggregatorFn) -> None: + self._fn = fn + + def aggregate_models(self, per_model, threshold, weights): + return self._fn(per_model, threshold, weights) + + +def _validate_metric(metric_name: str) -> None: + """Raise ValueError if metric_name is not an LLM metric.""" + if metric_name not in LLM_METRIC_NAMES: + raise ValueError(f"metric_name must be one of {sorted(LLM_METRIC_NAMES)}, got {metric_name!r}") + + +class LLMEvaluatorRegistry: + """Registry for pluggable LLM judge protocols (messages, scorer, samples, invocations, tools). + Plain functions are wrapped into protocol adapters; evaluators inject them into LLMJudge.""" + + def __init__(self) -> None: + self._messages_constructor: dict[str, MessagesConstructor] = {} + self._response_scorer: dict[str, ResponseScorer] = {} + self._samples_aggregator: dict[str, SamplesAggregator] = {} + self._invocations_aggregator: dict[str, InvocationsAggregator] = {} + self._models_aggregator: dict[str, ModelsAggregator] = {} + self._judge_tools: dict[str, List[Any]] = {} + + def register_messages_constructor(self, metric_name: str, fn: MessagesConstructorFn) -> None: + _validate_metric(metric_name) + self._messages_constructor[metric_name] = _MessagesConstructorAdapter(fn) + + def register_response_scorer(self, metric_name: str, fn: ResponseScorerFn) -> None: + _validate_metric(metric_name) + self._response_scorer[metric_name] = _ResponseScorerAdapter(fn) + + def register_samples_aggregator(self, metric_name: str, fn: SamplesAggregatorFn) -> None: + _validate_metric(metric_name) + self._samples_aggregator[metric_name] = _SamplesAggregatorAdapter(fn) + + def register_invocations_aggregator(self, metric_name: str, fn: InvocationsAggregatorFn) -> None: + _validate_metric(metric_name) + self._invocations_aggregator[metric_name] = _InvocationsAggregatorAdapter(fn) + + def register_models_aggregator(self, metric_name: str, fn: ModelsAggregatorFn) -> None: + _validate_metric(metric_name) + self._models_aggregator[metric_name] = _ModelsAggregatorAdapter(fn) + + def register_judge_tools(self, metric_name: str, tools: List[Any]) -> None: + """Register tools for the judge LlmAgent (e.g. BaseTool, BaseToolSet, or callables).""" + _validate_metric(metric_name) + self._judge_tools[metric_name] = list(tools) + + def get_judge_tools(self, metric_name: str) -> Optional[List[Any]]: + """Return registered tools for the judge agent, or None if not set.""" + return self._judge_tools.get(metric_name) + + def get_messages_constructor(self, metric_name: str) -> Optional[MessagesConstructor]: + return self._messages_constructor.get(metric_name) + + def get_response_scorer(self, metric_name: str) -> Optional[ResponseScorer]: + return self._response_scorer.get(metric_name) + + def get_samples_aggregator(self, metric_name: str) -> Optional[SamplesAggregator]: + return self._samples_aggregator.get(metric_name) + + def get_invocations_aggregator(self, metric_name: str) -> Optional[InvocationsAggregator]: + return self._invocations_aggregator.get(metric_name) + + def get_models_aggregator(self, metric_name: str) -> Optional[ModelsAggregator]: + return self._models_aggregator.get(metric_name) + + def unregister_messages_constructor(self, metric_name: str) -> None: + self._messages_constructor.pop(metric_name, None) + + def unregister_response_scorer(self, metric_name: str) -> None: + self._response_scorer.pop(metric_name, None) + + def unregister_samples_aggregator(self, metric_name: str) -> None: + self._samples_aggregator.pop(metric_name, None) + + def unregister_invocations_aggregator(self, metric_name: str) -> None: + self._invocations_aggregator.pop(metric_name, None) + + def unregister_models_aggregator(self, metric_name: str) -> None: + self._models_aggregator.pop(metric_name, None) + + def unregister_judge_tools(self, metric_name: str) -> None: + self._judge_tools.pop(metric_name, None) + + +LLM_EVALUATOR_REGISTRY = LLMEvaluatorRegistry() + + +def _judge_for_metric(eval_metric: EvalMetric) -> LLMJudge: + """Build LLMJudge for the metric, injecting registered protocols. + Uses built-in default when a protocol is not registered.""" + name = eval_metric.metric_name + return LLMJudge( + eval_metric, + messages_constructor=LLM_EVALUATOR_REGISTRY.get_messages_constructor(name), + response_scorer=LLM_EVALUATOR_REGISTRY.get_response_scorer(name), + samples_aggregator=LLM_EVALUATOR_REGISTRY.get_samples_aggregator(name), + invocations_aggregator=LLM_EVALUATOR_REGISTRY.get_invocations_aggregator(name), + models_aggregator=LLM_EVALUATOR_REGISTRY.get_models_aggregator(name), + judge_tools=LLM_EVALUATOR_REGISTRY.get_judge_tools(name), + ) + + +class LLMFinalResponseEvaluator(Evaluator): + """LLM judge for final response (valid/invalid). Metric: llm_final_response.""" + + requires_reference = True + + def __init__(self, eval_metric: Optional[EvalMetric] = None) -> None: + if not eval_metric: + raise ValueError("eval_metric is required for LLMFinalResponseEvaluator") + self._eval_metric = eval_metric + self._judge = _judge_for_metric(eval_metric) + + @override + async def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + return await self._judge.evaluate(actual_invocations, expected_invocations or []) + + +class LLMRubricResponseEvaluator(Evaluator): + """LLM rubric-based response quality. Metric: llm_rubric_response.""" + + requires_reference = False + + def __init__(self, eval_metric: Optional[EvalMetric] = None) -> None: + if not eval_metric: + raise ValueError("eval_metric is required for LLMRubricResponseEvaluator") + self._eval_metric = eval_metric + self._judge = _judge_for_metric(eval_metric) + + @override + async def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + return await self._judge.evaluate(actual_invocations, expected_invocations or []) + + +class LLMRubricKnowledgeRecallEvaluator(Evaluator): + """LLM rubric knowledge recall. Metric: llm_rubric_knowledge_recall.""" + + requires_reference = False + + def __init__(self, eval_metric: Optional[EvalMetric] = None) -> None: + if not eval_metric: + raise ValueError("eval_metric is required for LLMRubricKnowledgeRecallEvaluator") + self._eval_metric = eval_metric + self._judge = _judge_for_metric(eval_metric) + + @override + async def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + return await self._judge.evaluate(actual_invocations, expected_invocations or []) diff --git a/trpc_agent_sdk/evaluation/_llm_judge.py b/trpc_agent_sdk/evaluation/_llm_judge.py new file mode 100644 index 000000000..175ee374e --- /dev/null +++ b/trpc_agent_sdk/evaluation/_llm_judge.py @@ -0,0 +1,1353 @@ +# 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. +"""LLM Judge: build a judge agent from eval_metric and run evaluation via the agent.""" + +from __future__ import annotations + +import asyncio +import copy +import json +import os +import re +import uuid +from typing import Any +from typing import Optional +from typing import Protocol + +import json_repair +from pydantic import BaseModel as PydanticBaseModel +from pydantic import field_validator + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import create_agent_context +from trpc_agent_sdk.context import new_invocation_context_id +from trpc_agent_sdk.models import ModelRegistry +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.planners import BuiltInPlanner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import HttpOptions +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import ThinkingConfig + +from ._eval_case import IntermediateData +from ._eval_case import Invocation +from ._eval_case import get_all_tool_calls +from ._eval_case import get_all_tool_responses +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_result import EvaluationResult +from ._eval_result import NamedScoreResult +from ._eval_result import PerInvocationResult +from ._llm_criterion import LLMJudgeCriterion +from ._llm_criterion import JudgeModelOptions +from ._llm_criterion import Rubric +from ._llm_criterion import RubricScore +from ._llm_criterion import ScoreResult +from ._llm_criterion import get_llm_criterion_from_metric + + +class FinalResponseOutput(PydanticBaseModel): + """Pydantic schema for llm_final_response judge output (reasoning + valid/invalid).""" + reasoning: str + is_the_agent_response_valid: str # "valid" or "invalid" + + # Coerce non-string reasoning (e.g. nested object) to JSON repr. + @field_validator("reasoning", mode="before") + @classmethod + def _stringify_reasoning(cls, v: Any) -> str: + if v is None: + return "" + if isinstance(v, str): + return v + try: + return json.dumps(v, ensure_ascii=False) + except (TypeError, ValueError): + return str(v) + + +class RubricItemOutput(PydanticBaseModel): + """Schema for a single rubric item in judge output (id, rubric, evidence, reason, verdict).""" + id: str + rubric: str + evidence: str + reason: str + verdict: str # "yes" or "no" + + # Coerce scalar types (int/float/bool) into strings before validation. + @field_validator("id", "rubric", "evidence", "reason", "verdict", mode="before") + @classmethod + def _stringify(cls, v: Any) -> str: + if v is None: + return "" + if isinstance(v, str): + return v + if isinstance(v, bool): + # bool must precede int (bool is a subclass of int). + return "yes" if v else "no" + if isinstance(v, (int, float)): + return str(v) + try: + return json.dumps(v, ensure_ascii=False) + except (TypeError, ValueError): + return str(v) + + +class RubricJudgeOutput(PydanticBaseModel): + """Pydantic schema for llm_rubric_response and llm_rubric_knowledge_recall judge output.""" + items: list[RubricItemOutput] + + # Unpack items that were double-serialized as a JSON-encoded string. + @field_validator("items", mode="before") + @classmethod + def _unpack_items(cls, v: Any) -> Any: + if isinstance(v, str): + try: + return json.loads(v) + except (TypeError, ValueError, json.JSONDecodeError): + return v + return v + + +class MessagesConstructor(Protocol): + """Builds the user message sent to the judge model from invocations and criterion.""" + + def format_user_message( + self, + actuals: list[Invocation], + expecteds: Optional[list[Invocation]], + criterion: LLMJudgeCriterion, + metric_name: str, + ) -> str: + ... + + +class ResponseScorer(Protocol): + """Parses the judge model's raw response text into a ScoreResult by metric type.""" + + def parse_response(self, response_text: str, metric_name: str) -> ScoreResult: + ... + + +class SamplesAggregator(Protocol): + """Aggregates multiple judge samples (e.g. multiple runs) into a single ScoreResult.""" + + def aggregate_samples( + self, + samples: list[ScoreResult], + threshold: float, + ) -> ScoreResult: + ... + + +class InvocationsAggregator(Protocol): + """Aggregates per-invocation results into an overall score and EvalStatus.""" + + def aggregate_invocations( + self, + results: list[PerInvocationResult], + threshold: float, + ) -> tuple[Optional[float], EvalStatus]: + ... + + +class ModelsAggregator(Protocol): + """Aggregates per-model judge ScoreResults (single invocation, multiple judge models) into one ScoreResult.""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + ... + + +class MajorityVoteSamplesAggregator: + """Selects one sample by majority vote on pass/fail; on tie, prefers a failed sample if any.""" + + def aggregate_samples( + self, + samples: list[ScoreResult], + threshold: float, + ) -> ScoreResult: + if not samples: + raise ValueError("samples must not be empty") + passed = [s for s in samples if (s.score or 0) >= threshold] + failed = [s for s in samples if (s.score or 0) < threshold] + if len(samples) == 1: + return samples[0] + if len(passed) > len(failed): + return passed[0] + if len(failed) > 0: + return failed[0] + return samples[0] + + +class AverageInvocationsAggregator: + """Averages per-invocation scores; overall pass iff average >= threshold.""" + + def aggregate_invocations( + self, + results: list[PerInvocationResult], + threshold: float, + ) -> tuple[Optional[float], EvalStatus]: + scores = [r.score for r in results if r.eval_status != EvalStatus.NOT_EVALUATED and r.score is not None] + if not scores: + return (None, EvalStatus.NOT_EVALUATED) + overall = sum(scores) / len(scores) + status = EvalStatus.PASSED if overall >= threshold else EvalStatus.FAILED + return (overall, status) + + +def _format_per_model_reason(per_model: list[ScoreResult], threshold: float) -> str: + """Build a multi-line per-model breakdown string for ScoreResult.reason.""" + lines: list[str] = [] + for i, s in enumerate(per_model): + passed = (s.score or 0.0) >= threshold + snippet = (s.reason or "").replace("\n", " ").strip() + if len(snippet) > 200: + snippet = snippet[:200] + "..." + lines.append(f" model#{i}: score={s.score:.4f} passed={passed} reason={snippet}") + return "\n".join(lines) + + +class AllPassModelsAggregator: + """All models must pass (AND); returned score = min(scores).""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + scores = [s.score or 0.0 for s in per_model] + overall = min(scores) + passed_all = all(s >= threshold for s in scores) + base_reason = _format_per_model_reason(per_model, threshold) + reason = f"{base_reason}\naggregator=all_pass -> {'PASSED' if passed_all else 'FAILED'}" + return ScoreResult(score=overall, reason=reason) + + +class AnyPassModelsAggregator: + """Any model passing is enough (OR); returned score = max(scores).""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + scores = [s.score or 0.0 for s in per_model] + overall = max(scores) + passed_any = any(s >= threshold for s in scores) + base_reason = _format_per_model_reason(per_model, threshold) + reason = f"{base_reason}\naggregator=any_pass -> {'PASSED' if passed_any else 'FAILED'}" + return ScoreResult(score=overall, reason=reason) + + +class MajorityPassModelsAggregator: + """Strict majority must pass (passed*2 > total). Score = passed_count/total.""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + passed_count = sum(1 for s in per_model if (s.score or 0.0) >= threshold) + total = len(per_model) + overall = passed_count / total if total else 0.0 + passed_majority = passed_count * 2 > total + reason = (_format_per_model_reason(per_model, threshold) + f"\naggregator=majority_pass -> " + f"{'PASSED' if passed_majority else 'FAILED'} ({passed_count}/{total})") + return ScoreResult(score=overall, reason=reason) + + +class AverageModelsAggregator: + """Mean of scores.""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + scores = [s.score or 0.0 for s in per_model] + overall = sum(scores) / len(scores) + reason = (_format_per_model_reason(per_model, threshold) + f"\naggregator=avg -> mean={overall:.4f}") + return ScoreResult(score=overall, reason=reason) + + +class WeightedAverageModelsAggregator: + """Weighted mean: sum(w*s)/sum(w). Zero total -> 0.0.""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + if len(weights) != len(per_model): + raise ValueError(f"weights length {len(weights)} must equal per_model length {len(per_model)}") + total_w = sum(weights) + if total_w <= 0: + overall = 0.0 + else: + overall = sum(w * (s.score or 0.0) for w, s in zip(weights, per_model)) / total_w + base_reason = _format_per_model_reason(per_model, threshold) + reason = f"{base_reason}\naggregator=weighted_avg -> weighted_mean={overall:.4f} (total_w={total_w})" + return ScoreResult(score=overall, reason=reason) + + +class WeightedMajorityModelsAggregator: + """passed_weight*2 > total_weight (strict). Score = passed_weight/total_weight.""" + + def aggregate_models( + self, + per_model: list[ScoreResult], + threshold: float, + weights: list[float], + ) -> ScoreResult: + if not per_model: + raise ValueError("per_model must not be empty") + if len(weights) != len(per_model): + raise ValueError(f"weights length {len(weights)} must equal per_model length {len(per_model)}") + total_w = sum(weights) + passed_w = sum(w for w, s in zip(weights, per_model) if (s.score or 0.0) >= threshold) + if total_w <= 0: + overall = 0.0 + passed_majority = False + else: + overall = passed_w / total_w + passed_majority = passed_w * 2 > total_w + reason = (_format_per_model_reason(per_model, threshold) + f"\naggregator=weighted_majority -> " + f"{'PASSED' if passed_majority else 'FAILED'} " + f"(passed_w={passed_w}, total_w={total_w})") + return ScoreResult(score=overall, reason=reason) + + +_BUILTIN_MODELS_AGGREGATORS: dict[str, type] = { + "all_pass": AllPassModelsAggregator, + "any_pass": AnyPassModelsAggregator, + "majority_pass": MajorityPassModelsAggregator, + "avg": AverageModelsAggregator, + "weighted_avg": WeightedAverageModelsAggregator, + "weighted_majority": WeightedMajorityModelsAggregator, +} + + +def get_builtin_models_aggregator(name: str) -> Optional[ModelsAggregator]: + """Return a built-in ModelsAggregator instance by name, or None if unknown.""" + cls = _BUILTIN_MODELS_AGGREGATORS.get(name) + if cls is None: + return None + return cls() + + +def _extract_text_from_content(content: Any) -> str: + """Extract plain text from Content parts (concatenate part texts).""" + if content is None: + return "" + parts = content.parts + if not parts: + return "" + return "\n".join((p.text or "") for p in parts).strip() + + +def _extract_rubrics_text(rubrics: list[Rubric]) -> str: + """Format rubrics as lines of the form 'id: content.text'.""" + out = [] + for r in rubrics or []: + if not r or not r.content: + continue + out.append(f"{r.id}: {r.content.text}") + return "\n".join(out) + + +def _extract_retrieved_knowledge( + invocation: Invocation, + knowledge_tool_names: list[str], +) -> str: + """Extract tool responses from the invocation for tools in knowledge_tool_names, for judge input.""" + if not knowledge_tool_names: + return "No knowledge search results were found." + intermediate_data = invocation.intermediate_data + if not intermediate_data or not isinstance(intermediate_data, IntermediateData): + return "No knowledge search results were found." + tool_calls = get_all_tool_calls(intermediate_data) + tool_responses = get_all_tool_responses(intermediate_data) + allow = frozenset(knowledge_tool_names) + parts = [] + for call, resp in zip(tool_calls, tool_responses): + if not call or call.name not in allow: + continue + if not resp: + continue + payload = resp.response + if payload is None: + continue + try: + parts.append(json.dumps(payload, ensure_ascii=False) if isinstance(payload, (dict, list)) else str(payload)) + except (TypeError, ValueError): + parts.append(str(payload)) + if not parts: + return "No knowledge search results were found." + return "\n".join(parts) + + +class DefaultMessagesConstructor: + """Formats the judge user message from invocations and criterion. + Supports llm_final_response, llm_rubric_response, llm_rubric_knowledge_recall. + """ + + def __init__(self, user_template: str) -> None: + self._user_template = user_template + + def format_user_message( + self, + actuals: list[Invocation], + expecteds: Optional[list[Invocation]], + criterion: LLMJudgeCriterion, + metric_name: str, + ) -> str: + if not actuals: + raise ValueError("actuals is empty") + actual = actuals[-1] + if metric_name == "llm_final_response": + if not expecteds: + raise ValueError("expecteds is required for llm_final_response") + expected = expecteds[-1] + return self._user_template.format( + user_prompt=_extract_text_from_content(actual.user_content), + actual_response=_extract_text_from_content(actual.final_response), + expected_response=_extract_text_from_content(expected.final_response), + ) + if metric_name == "llm_rubric_response": + if not criterion.rubrics: + raise ValueError("llm_rubric_response requires criterion.rubrics") + return self._user_template.format( + user_input=_extract_text_from_content(actual.user_content), + final_response=_extract_text_from_content(actual.final_response), + rubrics=_extract_rubrics_text(criterion.rubrics), + ) + if metric_name == "llm_rubric_knowledge_recall": + if not criterion.rubrics: + raise ValueError("llm_rubric_knowledge_recall requires criterion.rubrics") + knowledge_tool_names = criterion.get_knowledge_tool_names() + retrieved = _extract_retrieved_knowledge(actual, knowledge_tool_names) + return self._user_template.format( + user_input=_extract_text_from_content(actual.user_content), + retrieved_knowledge=retrieved, + rubrics=_extract_rubrics_text(criterion.rubrics), + ) + raise ValueError(f"Unknown metric_name: {metric_name!r}") + + +_SALVAGE_MARK = "[salvaged]" + +# Verdict tokens recognized by salvage regex; canonicalized to yes/no. +_VERDICT_YES = {"yes", "true", "pass", "passed", "valid"} +_VERDICT_NO = {"no", "false", "fail", "failed", "invalid"} + + +class DefaultResponseScorer: + """Parses judge LLM output into a ScoreResult. + + Two-layer pipeline: + 1. json_repair.loads + Pydantic.model_validate (handles markdown fences, + single quotes, trailing commas, scalar-type coercion via field + validators on the schema classes). + 2. Regex salvage on the raw text when Pydantic rejects the dict. Extracts + only verdict tokens; does not fabricate rubric/reason/evidence. If no + verdict token is found, the caller raises (no silent zero-score). + """ + + def parse_response(self, response_text: str, metric_name: str) -> ScoreResult: + if metric_name == "llm_final_response": + return self._parse_final_response(response_text) + if metric_name in ("llm_rubric_response", "llm_rubric_knowledge_recall"): + return self._parse_rubric_response(response_text) + raise ValueError(f"unknown metric_name: {metric_name!r}") + + @staticmethod + def _load_json(text: str) -> Any: + """Lenient JSON load via json_repair (falls back to repair parser on failure).""" + return json_repair.loads(text or "") + + @staticmethod + def _salvage_final_response(text: str) -> Optional[ScoreResult]: + """Regex-extract the valid/invalid verdict; return None if not found.""" + if not text: + return None + m = re.search( + r'is_the_agent_response_valid["\s:=]+["\']?(valid|invalid)\b', + text, + re.IGNORECASE, + ) + if not m: + return None + label = m.group(1).strip().lower() + score = 1.0 if label == "valid" else 0.0 + return ScoreResult( + score=score, + reason=f"{_SALVAGE_MARK} verdict={label!r}; reasoning omitted", + ) + + @staticmethod + def _salvage_rubric_response(text: str) -> Optional[ScoreResult]: + """Regex-extract verdict tokens; return None if none found. + + Only verdict values are scraped — id/rubric/evidence/reason are not, + because positional alignment across free-form fields is unreliable. + """ + if not text: + return None + # `\\?` tolerates escaped quotes from double-serialized JSON strings. + matches = re.findall( + r'\\?["\']verdict\\?["\']\s*:\s*\\?["\']([A-Za-z]+)\\?["\']', + text, + ) + scores: list[float] = [] + for raw in matches: + tok = raw.strip().lower() + if tok in _VERDICT_YES: + scores.append(1.0) + elif tok in _VERDICT_NO: + scores.append(0.0) + # Unknown tokens dropped; never mapped to a guessed score. + if not scores: + return None + rubric_scores = [ + RubricScore( + id=f"salvaged_{i}", + reason=f"{_SALVAGE_MARK} original rubric text omitted", + score=s, + ) for i, s in enumerate(scores) + ] + avg = sum(scores) / len(scores) + return ScoreResult( + score=avg, + reason=f"{_SALVAGE_MARK} extracted {len(scores)} verdict(s); rubric/evidence/reason omitted", + rubric_scores=rubric_scores, + ) + + def _parse_final_response(self, response_text: str) -> ScoreResult: + try: + data = self._load_json(response_text) + obj = FinalResponseOutput.model_validate(data) + except Exception as e: + salvaged = self._salvage_final_response(response_text) + if salvaged is not None: + return salvaged + preview = f"; got: {(response_text or '')[:200]!r}" if response_text else "" + raise ValueError(f"failed to parse final response JSON: {e}{preview}") from e + label = obj.is_the_agent_response_valid.strip().lower() + score = 1.0 if label == "valid" else 0.0 + return ScoreResult(score=score, reason=obj.reasoning.strip()) + + def _parse_rubric_response(self, response_text: str) -> ScoreResult: + try: + data = self._load_json(response_text) + obj = RubricJudgeOutput.model_validate(data) + except Exception as e: + salvaged = self._salvage_rubric_response(response_text) + if salvaged is not None: + return salvaged + preview = f"; got: {(response_text or '')[:500]!r}" if response_text else "" + raise ValueError(f"failed to parse rubric response JSON: {e}{preview}") from e + if not obj.items: + raise ValueError("rubric response JSON contains empty items array") + rubric_scores: list[RubricScore] = [] + reasons: list[str] = [] + for item in obj.items: + verdict = item.verdict.strip().lower() + score = 1.0 if verdict == "yes" else 0.0 + rubric_scores.append(RubricScore(id=item.id, reason=item.reason.strip(), score=score), ) + reasons.append(item.reason.strip()) + avg = sum(r.score for r in rubric_scores) / len(rubric_scores) + return ScoreResult( + score=avg, + reason="\n".join(reasons), + rubric_scores=rubric_scores, + ) + + +FINAL_RESPONSE_PROMPT = """ +You are an expert evaluator for an AI agent (Agent: a model that executes tasks). +Your job is to **only** judge whether the agent's **final answer** matches the +reference answer, and to output a fixed-format plain-text report. + +### Core scoring rules + +1. **The reference answer is the only Ground Truth (Ground Truth: the official + "correct" answer used for evaluation).** + No matter whether you personally think the reference answer might be wrong, + outdated, or unreasonable, you **must** treat it as absolutely correct. + +* Your job is not to fact-check or correct the reference answer, but to judge + whether the agent's answer is aligned with it. +* If the agent's answer does not match the reference answer, then even if you + think the agent is "more correct," you must mark it **invalid**. + +2. **Clarification questions are never allowed.** + If the agent asks the user for more information, requests clarification, + asks follow-up questions, or tells the user to provide missing conditions, + it is considered **not completing the task**, and must be marked **invalid**. + (Examples: "Please provide more details / what exactly do you want / can you + share the date and location?") + +3. **No independent verification or calculation.** + +* If the user prompt includes CSV (Comma-Separated Values, a table-like text + format where values are separated by commas) or other tabular data: do + **not** parse or calculate it yourself. Always follow the reference answer. +* If math, date arithmetic, or unit conversion is needed: do **not** compute it + yourself. Always follow the reference answer. + +### Input + +You will receive three items wrapped in XML tags: + +* : the user's question +* : the agent's answer +* : the reference answer (the only Ground Truth) + +### Matching rules + +As long as the meaning does not change, the following differences are allowed +and can still be considered a match (**valid**): + +* **Formatting differences**: list vs. paragraph; line breaks, punctuation, or + slightly different ordering (as long as the key information is unchanged). +* **Equivalent writing**: different number formatting (e.g., 1000000 vs + 1,000,000), different capitalization. +* **Paraphrases**: as long as the key entities (Key Entities: the critical + items required by the answer) and main components clearly align with the + reference answer. + +Must mark **invalid** in typical cases: + +* **Missing key information**: the agent does not include all key entities / + core fields required by the reference answer. +* **Key information mismatch**: numbers, conclusions, objects, units, etc. + differ from the reference answer. + + * Pay special attention to units: for example, if the reference answer is + 100 miles but the agent writes 100 km, it must be **invalid**. +* **Clarification / deflection / refusal**: any response that asks for more + input, turns into a question, or fails to directly provide the required + result must be **invalid**. + +### Output requirements + +Your output must be a JSON object with exactly two fields: + +* "reasoning": string. Briefly explain why you judged valid/invalid, pointing to + the key aligned or misaligned points. +* "is_the_agent_response_valid": string, must be exactly "valid" or "invalid". + +Example output: +{"reasoning": "The agent answered Paris which matches the reference.", "is_the_agent_response_valid": "valid"} + +Requirement: be assertive and unambiguous; do not hedge. Output ONLY the JSON +object, no other text. +""" + +RUBRIC_RESPONSE_PROMPT = """ +# Mission + +Your mission is to evaluate the quality of an AI agent's final answer. You will +be shown a user prompt (), the agent's response (, which +contains ), and a rubric (). You must use the rubric to +objectively assess whether the agent's final answer satisfies each rubric item. +Only respond to the rubric items provided. Do not invent new rubric items. + +# Rubric + +"yes": The final answer fulfills the rubric item, OR the rubric item's +condition was not applicable to the response. +"no": The rubric item is applicable but the final answer fails to fulfill it, +OR the rubric item requires a fact/conclusion that cannot be unambiguously +verified from and (i.e., it is ambiguous or lacks +checkable information). + +# Key Evaluation Principles + +1. **Evaluate final answer content only** + You must evaluate only whether satisfies each rubric item in + . Do not evaluate tool usage, intermediate steps, chain-of-thought, + or any process artifacts. + +2. **Restricted evidence sources** + Your judgment may only be based on: + +* the original text of (the user's requirements and any given + information), and +* the text of (the agent's final output). + Do not use external knowledge, common-sense guessing, or additional + background to "fill in" missing information. + +3. **Allow semantic equivalence** + As long as the rubric item is still satisfied, accept different wording, + formatting, and paraphrases. + For numbers, accept numerically equivalent expressions (different + representations), and allow minor rounding/precision differences as long as + they do not change the final conclusion. + +4. **Conditional rubric items (not applicable => yes)** + If a rubric item is conditional (e.g., "If … then …"), you may mark it as + not applicable and return "yes" only if you can clearly determine from + and that the condition is not met. + If you cannot determine whether the condition is met, you may not mark it as + "probably not applicable." Treat it as not fulfilled (typically "no"). + +# Output Format + +Your output must be a JSON object with a single key "items", whose value is an +array of objects. Each object corresponds to one rubric item and has exactly +these five fields: + +* "id": The ID of the rubric item (must match the rubric numbering). +* "rubric": Repeat the rubric item word-for-word without changes. +* "evidence": Evidence text snippets from and/or . + If no evidence is required, explain why. If it cannot be verified, explain why. +* "reason": Your reasoning: how evidence supports/contradicts the final answer, + or why the rubric item is not applicable. +* "verdict": exactly "yes" or "no". + +Example output: +{"items": [{"id": "1", "rubric": "...", "evidence": "...", "reason": "...", "verdict": "yes"}]} + +REMEMBER: Your answer will help improve the AI agent. It is important to +determine whether rubric items are fulfilled correctly. Even answering "no" can +improve the agent! Output ONLY the JSON object, no other text. + +# Your Turn + +## Input + + + +{{user_input}} + + + + + + {{final_response}} + + + + +{{rubrics}} + + +## Output +""" + +RUBRIC_KNOWLEDGE_RECALL_PROMPT = """ +# Mission + +Your mission is to evaluate whether the retrieved knowledge () +is relevant to the user question (), and whether it is sufficient to +support each rubric item in the rubric (). +You will be given: the user question (), the retrieved documents +(), and the rubric (). +Only respond to the rubric items provided. Do not invent new rubric items. + +# Rubric + +"yes": The retrieved knowledge **directly supports** the key information required +by the rubric item, OR the rubric item's condition is clearly not applicable to +this user question. +"no": The rubric item is applicable, but the retrieved knowledge is missing, +insufficient, only broadly on-topic, or clearly irrelevant, and therefore cannot +support the rubric item. + +# Key Evaluation Principles + +1. **Trusted evidence comes from retrieved documents only** + You may only use content from as trusted evidence. Do + not use any final answer text, model reasoning, external knowledge, or + common-sense guessing to fill in missing information. + +2. **Relevance first, and it must be answerable** + Even if the retrieved knowledge contains correct facts, it must be relevant + to the user's intent and usable for satisfying the corresponding rubric item. + If it is merely "same topic / loosely related / generic background" but does + not support the required information for the rubric item, answer "no". + +3. **Sufficiency must be judged with an operational test** + For each rubric item, use the following test to determine whether the + retrieval is "sufficient": + +* Imagine an answerer who can only see (no external + knowledge, no guessing). +* If they could complete the rubric item using only that retrieved knowledge + (i.e., produce the key conclusion/elements required), then the item may be + "yes". +* If they could not (missing key entities, steps, numbers, conditions, + definitions, etc.), then it must be "no". + +4. **Evidence must be close to the original text; no abstract fabrication** + Evidence must come from , with these requirements: + +* Prefer **verbatim excerpts** (you may truncate, but must not change meaning). +* If you must paraphrase, it must be a **near-verbatim** paraphrase that can be + directly located in the documents. +* If contains document IDs/titles/sectioning, you must + cite the source location in Evidence (e.g., "Doc 2 / Paragraph 3"). If there + is no numbering, include enough raw text to make the source identifiable. + +5. **Conditional rubric items (not applicable => yes)** + If a rubric item is conditional (e.g., "If … then …"): + +* You may return "yes" as not applicable only if you can **clearly determine + from ** that the condition is not met. +* If you cannot determine whether the condition is met, treat the item as + applicable; if retrieval is insufficient, return "no". +* When you return "yes" due to not-applicable, your Reason must explicitly + state what part of makes it not applicable. + +6. **No extra output** + You must output only the per-item evaluations in the format below. Do not add + any overall summary or additional commentary. + +# Internal steps for each rubric item (for internal analysis only) + +1. Understand the rubric item and the key evaluation principles. +2. Collect relevant excerpts from as evidence. +3. Judge whether the evidence is relevant and sufficient (using the sufficiency test). +4. Output the verdict in the required format. + Note: These steps are for your internal analysis only and must not be output. + +# Output Format + +Your output must be a JSON object with a single key "items", whose value is an +array of objects. Each object corresponds to one rubric item and has exactly +these five fields: + +* "id": The ID of the rubric item (must match the rubric numbering). +* "rubric": Repeat the rubric item word-for-word without changes. +* "evidence": Relevant verbatim excerpts (or near-verbatim paraphrases) from + , with source/location where possible. If there is no + relevant evidence, write "none". +* "reason": Explain why the evidence directly supports the rubric item, or why + evidence is missing/insufficient/irrelevant; or explain why the item is not + applicable, and cite which part of establishes that. +* "verdict": exactly "yes" or "no". + +Example output: +{"items": [{"id": "1", "rubric": "...", "evidence": "...", "reason": "...", "verdict": "yes"}]} + +Output ONLY the JSON object, no other text. + +# Your Turn + +## Input + + + +{{user_input}} + + + + +{{retrieved_knowledge}} + + + +{{rubrics}} + + +## Output +""" + + +def _rubric_system(prompt: str) -> str: + """Return the system part of a rubric prompt: content before '# Your Turn' plus '## Output'.""" + return prompt.split("# Your Turn")[0].strip() + "\n\n## Output" + + +def _expand_env(s: str) -> str: + """Expand environment variables in a string (e.g. $VAR or ${VAR}).""" + if not s or not isinstance(s, str): + return s or "" + return os.path.expandvars(s) + + +def _create_judge_model(opts: JudgeModelOptions) -> Any: + """Build the underlying LLM model for one judge option. + + Provider routing: + - provider_name empty or "openai" -> OpenAIModel(...) directly. This + matches the framework's standard pattern for OpenAI-compatible + endpoints (see examples/llmagent/) and ensures http_options.extra_body + (e.g. chat_template_kwargs.enable_thinking used by judge `think` field) + is forwarded to the backend. Routing via "openai/" through + ModelRegistry lands on LiteLLMModel whose current implementation + drops extra_body. + - Any other provider_name -> ModelRegistry.create_model("{provider}/{model}") + which routes to LiteLLMModel for multi-provider support. + """ + provider_name = _expand_env(opts.provider_name or "") + model_name = _expand_env(opts.model_name or "") + base_url = _expand_env(opts.base_url or "") + api_key = _expand_env(opts.api_key or "") + extra = dict(opts.extra_fields or {}) + + if not provider_name or provider_name.lower() == "openai": + # Direct OpenAIModel instantiation bypasses ModelRegistry regex routing, + # so any model_name (e.g. "glm-5.1-w4afp8") works against any + # OpenAI-compatible endpoint. + return OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=base_url or None, + **extra, + ) + + model_str = f"{provider_name}/{model_name}" + return ModelRegistry.create_model( + model_str, + api_key=api_key, + base_url=base_url or "", + **extra, + ) + + +# Default judge generation params when not specified in criterion. +DEFAULT_JUDGE_MAX_TOKENS = 4096 +DEFAULT_JUDGE_TEMPERATURE = 0.8 + + +def _merge_extra_body( + http_options: Optional[HttpOptions], + patch: dict[str, Any], +) -> HttpOptions: + """Deep-merge patch into http_options.extra_body at nested-dict granularity. + + - None http_options -> returns new HttpOptions(extra_body=deepcopy(patch)). + - For top-level keys in patch: if both sides have dict, merge recursively (deep-copying + patch values); otherwise patch value wins. + - Other existing top-level keys in http_options.extra_body are preserved. + """ + base = (http_options.extra_body or {}) if http_options is not None else {} + merged: dict[str, Any] = dict(base) + for key, patch_val in patch.items(): + base_val = merged.get(key) + if isinstance(base_val, dict) and isinstance(patch_val, dict): + new_child = dict(base_val) + for subkey, subval in patch_val.items(): + new_child[subkey] = copy.deepcopy(subval) + merged[key] = new_child + else: + merged[key] = copy.deepcopy(patch_val) + if http_options is None: + return HttpOptions(extra_body=merged) + return http_options.model_copy(update={"extra_body": merged}) + + +def _judge_generation_config( + gen: dict[str, Any] | None, + think: Optional[bool], +) -> tuple[GenerateContentConfig, Optional[ThinkingConfig]]: + """Build GenerateContentConfig from criterion generation_config and resolve thinking config. + + Returns (cfg, effective_thinking_config): + - cfg: GenerateContentConfig WITHOUT thinking_config set (LlmAgent rejects it; + thinking_config must be applied via BuiltInPlanner). + - effective_thinking_config: None means caller should not build a planner; + otherwise caller wraps it in BuiltInPlanner. + + Resolution order: + 1. Parse gen for base fields (max_tokens/temperature/top_p/stop/...). + 2. Parse gen["thinking_config"] dict into a candidate ThinkingConfig (not written to cfg). + 3. Parse gen["http_options"] dict into cfg.http_options (if present). + 4. If `think` is not None, override the candidate ThinkingConfig and deep-merge + chat_template_kwargs.enable_thinking into cfg.http_options (preserving siblings). + """ + gen = gen or {} + cfg = GenerateContentConfig() + cfg.max_output_tokens = (gen.get("max_tokens") or gen.get("max_output_tokens") or DEFAULT_JUDGE_MAX_TOKENS) + cfg.temperature = gen.get("temperature", DEFAULT_JUDGE_TEMPERATURE) + if "top_p" in gen and gen["top_p"] is not None: + cfg.top_p = gen["top_p"] + if "stop" in gen and gen["stop"] is not None: + cfg.stop_sequences = gen["stop"] if isinstance(gen["stop"], list) else [gen["stop"]] + elif "stop_sequences" in gen and gen["stop_sequences"] is not None: + cfg.stop_sequences = gen["stop_sequences"] + if "presence_penalty" in gen and gen["presence_penalty"] is not None: + setattr(cfg, "presence_penalty", gen["presence_penalty"]) + if "frequency_penalty" in gen and gen["frequency_penalty"] is not None: + setattr(cfg, "frequency_penalty", gen["frequency_penalty"]) + + # Parse thinking_config dict from generation_config (candidate; may be overridden by `think`). + effective_thinking_config: Optional[ThinkingConfig] = None + tc_dict = gen.get("thinking_config") + if isinstance(tc_dict, dict): + effective_thinking_config = ThinkingConfig(**tc_dict) + + # Parse http_options dict from generation_config, if any. + http_opts_dict = gen.get("http_options") + if isinstance(http_opts_dict, dict): + cfg.http_options = HttpOptions(**http_opts_dict) + + # `think` field overrides both paths when set. + if think is True: + effective_thinking_config = ThinkingConfig( + include_thoughts=True, + thinking_budget=-1, + ) + cfg.http_options = _merge_extra_body( + cfg.http_options, + {"chat_template_kwargs": { + "enable_thinking": True + }}, + ) + elif think is False: + effective_thinking_config = ThinkingConfig( + include_thoughts=False, + thinking_budget=0, + ) + cfg.http_options = _merge_extra_body( + cfg.http_options, + {"chat_template_kwargs": { + "enable_thinking": False + }}, + ) + + return cfg, effective_thinking_config + + +class _JudgeAgent: + """Runs the judge via LlmAgent: system as instruction, single turn, optional output_schema.""" + + def __init__( + self, + model: Any, + config: GenerateContentConfig, + system_prompt: str, + output_schema: Optional[type[PydanticBaseModel]] = None, + tools: Optional[list] = None, + planner: Optional[Any] = None, + ) -> None: + self._agent = LlmAgent( + name="judge", + model=model, + instruction=system_prompt, + generate_content_config=config, + add_name_to_instruction=False, + output_schema=output_schema, + tools=tools or [], + planner=planner, + ) + self._session_service = InMemorySessionService() + + async def get_response(self, user_message: str) -> str: + user_content = Content(role="user", parts=[Part.from_text(text=user_message)]) + agent_context = create_agent_context() + session = await self._session_service.create_session( + app_name="eval", + user_id="judge", + session_id=str(uuid.uuid4()), + agent_context=agent_context, + ) + ctx = InvocationContext( + session_service=self._session_service, + invocation_id=new_invocation_context_id(), + agent=self._agent, + session=session, + agent_context=agent_context, + user_content=user_content, + override_messages=[user_content], + ) + last_text = "" + async for event in self._agent.run_async(ctx): + if not event.is_final_response(): + continue + if not event.content or not event.content.parts: + continue + part_text = "\n".join((p.text or "").strip() for p in event.content.parts if p.thought is not True).strip() + if part_text: + last_text += part_text + return last_text.strip() + + +class LLMJudge: + """Builds judge agent(s) from eval_metric. Supports 1..N judge models with cross-model aggregation. + + Pluggable: messages_constructor, response_scorer, samples_aggregator, invocations_aggregator, + models_aggregator, judge_tools. + + models_aggregator resolution order: + 1) explicit constructor argument (if any) + 2) registry-registered ModelsAggregator for metric_name (resolved by caller, e.g. _judge_for_metric) + 3) criterion.models_aggregator string -> built-in 6 names + 4) fallback: all_pass + """ + + def __init__( + self, + eval_metric: EvalMetric, + *, + messages_constructor: Optional[MessagesConstructor] = None, + response_scorer: Optional[ResponseScorer] = None, + samples_aggregator: Optional[SamplesAggregator] = None, + invocations_aggregator: Optional[InvocationsAggregator] = None, + models_aggregator: Optional[ModelsAggregator] = None, + judge_tools: Optional[list] = None, + ) -> None: + if not eval_metric: + raise ValueError("LLMJudge requires eval_metric") + self._eval_metric = eval_metric + criterion = get_llm_criterion_from_metric(eval_metric) + if not criterion: + raise ValueError("eval_metric.criterion.llmJudge is required") + judge_models_list = criterion.get_judge_models() + if not judge_models_list: + raise ValueError("eval_metric.criterion.llmJudge requires either judge_model or judge_models") + self._criterion = criterion + self._metric_name = eval_metric.metric_name or "" + self._judge_models: list[JudgeModelOptions] = judge_models_list + self._parallel: bool = bool(criterion.parallel) + + # Resolve models_aggregator: explicit > built-in name lookup > error. + resolved_models_agg = models_aggregator + if resolved_models_agg is None: + agg_name = criterion.models_aggregator or "all_pass" + built = get_builtin_models_aggregator(agg_name) + if built is None: + raise ValueError(f"models_aggregator {agg_name!r} is not a built-in name; " + f"register it via LLM_EVALUATOR_REGISTRY.register_models_aggregator " + f"before constructing LLMJudge") + resolved_models_agg = built + self._models_aggregator: ModelsAggregator = resolved_models_agg + + # Pick metric-specific system prompt + user template + output schema (unchanged from before). + if self._metric_name == "llm_final_response": + system_prompt = FINAL_RESPONSE_PROMPT + user_template = ("\n" + "{user_prompt}\n" + "\n" + "\n" + "\n" + "{actual_response}\n" + "\n" + "\n" + "\n" + "{expected_response}\n" + "") + output_schema: Optional[type[PydanticBaseModel]] = FinalResponseOutput + elif self._metric_name == "llm_rubric_response": + system_prompt = _rubric_system(RUBRIC_RESPONSE_PROMPT) + user_template = ("\n" + "\n" + "{user_input}\n" + "\n" + "\n" + "\n" + "\n" + " \n" + " {final_response}\n" + " \n" + "\n" + "\n" + "\n" + "{rubrics}\n" + "") + output_schema = RubricJudgeOutput + elif self._metric_name == "llm_rubric_knowledge_recall": + system_prompt = _rubric_system(RUBRIC_KNOWLEDGE_RECALL_PROMPT) + user_template = ("\n" + "\n" + "{user_input}\n" + "\n" + "\n" + "\n" + "\n" + "{retrieved_knowledge}\n" + "\n" + "\n" + "\n" + "{rubrics}\n" + "") + output_schema = RubricJudgeOutput + else: + raise ValueError(f"Unsupported metric_name for LLMJudge: {self._metric_name!r}") + + # Build one _JudgeAgent per judge model option, in order. + self._judge_agents: list[_JudgeAgent] = [] + for opts in judge_models_list: + model = _create_judge_model(opts) + cfg, effective_tc = _judge_generation_config(opts.generation_config, opts.think) + planner = (BuiltInPlanner(thinking_config=effective_tc) if effective_tc is not None else None) + self._judge_agents.append( + _JudgeAgent( + model, + cfg, + system_prompt, + output_schema=output_schema, + tools=judge_tools, + planner=planner, + )) + + self._messages_constructor = messages_constructor or DefaultMessagesConstructor(user_template) + self._response_scorer = response_scorer or DefaultResponseScorer() + self._samples_aggregator = samples_aggregator or MajorityVoteSamplesAggregator() + self._invocations_aggregator = invocations_aggregator or AverageInvocationsAggregator() + + def get_num_samples(self) -> int: + """Return num_samples for the *first* judge model (legacy single-model API). + + Multi-model judges may use different num_samples per model; callers that need + per-model sample counts should iterate criterion.get_judge_models() directly. + """ + return self._criterion.get_num_samples() + + async def _run_one_judge( + self, + agent_index: int, + opts: JudgeModelOptions, + user_message: str, + threshold: float, + ) -> "tuple[NamedScoreResult, ScoreResult, bool]": + """Run num_samples calls for one judge model, then SamplesAggregator. + + Returns (named_score, raw_score_result, had_exception). On exception, returns + a soft-failure NamedScoreResult with passed=False, score=0.0, reason=str(exc), + and had_exception=True. + """ + agent = self._judge_agents[agent_index] + n = opts.get_num_samples() + try: + samples: list[ScoreResult] = [] + for _ in range(n): + response_text = await agent.get_response(user_message) + samples.append(self._response_scorer.parse_response(response_text, self._metric_name)) + chosen = self._samples_aggregator.aggregate_samples(samples, threshold) + except Exception as exc: + named = NamedScoreResult( + model_name=opts.model_name or "", + provider_name=opts.provider_name or "", + score=0.0, + reason=str(exc), + rubric_scores=[], + passed=False, + ) + return named, ScoreResult(score=0.0, reason=str(exc)), True + passed = (chosen.score or 0.0) >= threshold + named = NamedScoreResult( + model_name=opts.model_name or "", + provider_name=opts.provider_name or "", + score=chosen.score or 0.0, + reason=chosen.reason or "", + rubric_scores=list(chosen.rubric_scores or []), + passed=passed, + ) + return named, chosen, False + + async def evaluate( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + """Run multi-model judge per invocation, aggregate per-model + per-invocation.""" + if expected_invocations is None: + expected_invocations = [] + if len(actual_invocations) != len(expected_invocations): + raise ValueError(f"actual_invocations ({len(actual_invocations)}) and " + f"expected_invocations ({len(expected_invocations)}) length mismatch") + + threshold = self._eval_metric.threshold + weights = [m.weight for m in self._judge_models] + per_invocation_results: list[PerInvocationResult] = [] + + for i in range(len(actual_invocations)): + actual = actual_invocations[i] + expected = expected_invocations[i] if i < len(expected_invocations) else None + + user_message = self._messages_constructor.format_user_message( + actual_invocations[:i + 1], + expected_invocations[:i + 1] if expected_invocations else None, + self._criterion, + self._metric_name, + ) + + # Step 1: each model runs its own samples + SamplesAggregator -> (named, raw, had_exception) + if self._parallel and len(self._judge_models) > 1: + tasks = [ + self._run_one_judge(idx, opts, user_message, threshold) + for idx, opts in enumerate(self._judge_models) + ] + triples = await asyncio.gather(*tasks) + else: + triples = [] + for idx, opts in enumerate(self._judge_models): + triples.append(await self._run_one_judge(idx, opts, user_message, threshold)) + + named_results: list[NamedScoreResult] = [t[0] for t in triples] + score_results: list[ScoreResult] = [t[1] for t in triples] + exceptions: list[bool] = [t[2] for t in triples] + + # Step 2: if every model raised, mark NOT_EVALUATED. + all_exception = all(exceptions) and len(exceptions) > 0 + + if all_exception: + per_invocation_results.append( + PerInvocationResult( + actual_invocation=actual, + expected_invocation=expected, + score=None, + eval_status=EvalStatus.NOT_EVALUATED, + reason="all judge models failed: " + "; ".join(f"{n.model_name}={n.reason}" + for n in named_results), + rubric_scores=None, + per_model_scores=named_results, + )) + continue + + # Step 3: cross-model aggregation -> single ScoreResult + invocation_score = self._models_aggregator.aggregate_models( + score_results, + threshold, + weights, + ) + status = (EvalStatus.PASSED if (invocation_score.score or 0.0) >= threshold else EvalStatus.FAILED) + rubric_scores = (list(invocation_score.rubric_scores) if invocation_score.rubric_scores else None) + per_invocation_results.append( + PerInvocationResult( + actual_invocation=actual, + expected_invocation=expected, + score=invocation_score.score, + eval_status=status, + reason=invocation_score.reason or None, + rubric_scores=rubric_scores, + per_model_scores=named_results, + )) + + overall_score, overall_status = self._invocations_aggregator.aggregate_invocations( + per_invocation_results, + threshold, + ) + return EvaluationResult( + overall_score=overall_score, + overall_eval_status=overall_status, + per_invocation_results=per_invocation_results, + ) diff --git a/trpc_agent_sdk/evaluation/_local_eval_service.py b/trpc_agent_sdk/evaluation/_local_eval_service.py new file mode 100644 index 000000000..2ea1a8d76 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_local_eval_service.py @@ -0,0 +1,854 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Local evaluation service implementation. + +This module provides LocalEvalService for running agent evaluations locally. +""" + +from __future__ import annotations + +import asyncio +import inspect +import time +import uuid +from typing import Any +from typing import AsyncGenerator +from typing import Callable +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.artifacts import BaseArtifactService +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.log import error as log_error +from trpc_agent_sdk.memory import BaseMemoryService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content + +from ._eval_callbacks import Callbacks +from ._eval_callbacks import CallbacksRunner +from ._eval_callbacks import EvalSetRunResult +from ._eval_case import EvalCase +from ._eval_case import EvalModeTrace +from ._eval_case import IntermediateData +from ._eval_case import Invocation +from ._eval_case import InvocationEvent +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_result import EvalCaseResult +from ._eval_result import EvalMetricResult +from ._eval_result import EvalMetricResultDetails +from ._eval_result import EvalMetricResultPerInvocation +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._eval_service_base import BaseEvalService +from ._eval_service_base import EvaluateConfig +from ._eval_service_base import EvaluateRequest +from ._eval_service_base import InferenceRequest +from ._eval_service_base import InferenceResult +from ._eval_service_base import InferenceStatus +from ._eval_session_service import EvalSessionService +from ._eval_set_results_manager_base import EvalSetResultsManager +from ._eval_sets_manager_base import EvalSetsManager +from ._evaluator_registry import EVALUATOR_REGISTRY +from ._evaluator_registry import EvaluatorRegistry +from ._user_simulator_base import Status +from ._user_simulator_provider import UserSimulatorProvider + +EVAL_SESSION_ID_PREFIX = "___eval___session___" +DEFAULT_EVAL_USER_ID = "test_user_id" + + +def _get_session_id() -> str: + """Generate a unique session ID for evaluation.""" + return f"{EVAL_SESSION_ID_PREFIX}{str(uuid.uuid4())}" + + +class LocalEvalService(BaseEvalService): + """Local implementation of evaluation service. + + This service runs evaluations locally using the TRPC agent framework. + """ + + def __init__( + self, + root_agent: Optional[BaseAgent], + eval_sets_manager: EvalSetsManager, + evaluator_registry: Optional[EvaluatorRegistry] = None, + session_service: Optional[BaseSessionService] = None, + artifact_service: Optional[BaseArtifactService] = None, + memory_service: Optional[BaseMemoryService] = None, + eval_set_results_manager: Optional[EvalSetResultsManager] = None, + session_id_supplier: Callable[[], str] = _get_session_id, + user_simulator_provider=None, + runner: Optional[Runner] = None, + callbacks: Optional[Callbacks] = None, + ): + """Initialize the local evaluation service. + + Args: + root_agent: The agent to evaluate. May be ``None`` only when every + eval case to be processed uses ``eval_mode='trace'``; standard + or mixed modes require a concrete agent. + eval_sets_manager: Manager for eval sets storage + evaluator_registry: Registry of metric evaluators + session_service: Session service for maintaining state + artifact_service: Artifact service (optional) + memory_service: Memory service (optional) + eval_set_results_manager: Manager for saving evaluation results + session_id_supplier: Function to generate session IDs + user_simulator_provider: Provider for user simulators + runner: Optional user-provided Runner; when set, use it as-is and only + update its session when session_input exists and values are set in case. + callbacks: Optional lifecycle callbacks (before/after inference and evaluate). + """ + self._root_agent = root_agent + self._eval_sets_manager = eval_sets_manager + self._evaluator_registry = evaluator_registry or EVALUATOR_REGISTRY + self._session_service = session_service or InMemorySessionService() + self._artifact_service = artifact_service + self._memory_service = memory_service + self._eval_set_results_manager = eval_set_results_manager + self._session_id_supplier = session_id_supplier + self._user_simulator_provider = user_simulator_provider or UserSimulatorProvider() + self._runner = runner + self._callbacks = callbacks + self._callbacks_runner = CallbacksRunner(callbacks or Callbacks()) + + @staticmethod + def _get_user_id_from_eval_case(eval_case: EvalCase) -> tuple[str, bool]: + """Get user_id from eval case session_input, or default. Returns (user_id, set_in_case).""" + if eval_case.session_input and eval_case.session_input.user_id: + return (eval_case.session_input.user_id, True) + return (DEFAULT_EVAL_USER_ID, False) + + @staticmethod + def _get_initial_state_from_eval_case(eval_case: EvalCase) -> tuple[dict[str, Any], bool]: + """Get initial session state from eval case session_input. Returns (state, set_in_case).""" + if eval_case.session_input: + return (eval_case.session_input.state or {}, True) + return ({}, False) + + @staticmethod + def _get_session_app_name(eval_case: EvalCase, fallback_app_name: str) -> tuple[str, bool]: + """App name for session storage. Returns (app_name, set_in_case).""" + if eval_case.session_input and eval_case.session_input.app_name: + return (eval_case.session_input.app_name, True) + return (fallback_app_name, False) + + @override + async def perform_inference( + self, + inference_request: InferenceRequest, + ) -> AsyncGenerator[InferenceResult, None]: + """Generate inferences for eval cases. + + Args: + inference_request: The inference request + + Yields: + InferenceResult for each eval case + """ + # Get the eval set from storage + eval_set = self._eval_sets_manager.get_eval_set( + app_name=inference_request.app_name, + eval_set_id=inference_request.eval_set_id, + ) + + if not eval_set: + raise ValueError(f"Eval set with id {inference_request.eval_set_id} not found for app " + f"{inference_request.app_name}") + + # Select eval cases for inferencing + eval_cases = eval_set.eval_cases + if inference_request.eval_case_ids: + eval_cases = [eval_case for eval_case in eval_cases if eval_case.eval_id in inference_request.eval_case_ids] + + run_ctx: dict[str, Any] = {} + start_time = time.monotonic() + inference_results_list: list[InferenceResult] = [] + set_error: Optional[Exception] = None + + await self._callbacks_runner.run_before_inference_set(inference_request, run_ctx) + + semaphore = asyncio.Semaphore(value=inference_request.inference_config.parallelism) + + async def run_one(eval_case: EvalCase) -> InferenceResult: + case_ctx = run_ctx.copy() + session_id = self._session_id_supplier() + await self._callbacks_runner.run_before_inference_case(inference_request, eval_case.eval_id, session_id, + case_ctx) + case_start = time.monotonic() + async with semaphore: + result = await self._perform_inference_single_eval_item( + app_name=inference_request.app_name, + eval_set_id=inference_request.eval_set_id, + eval_case=eval_case, + root_agent=self._root_agent, + session_id=session_id, + ) + await self._callbacks_runner.run_after_inference_case(inference_request, result, None, case_start, + case_ctx) + return result + + try: + tasks = [run_one(eval_case) for eval_case in eval_cases] + for coro in asyncio.as_completed(tasks): + inference_result = await coro + inference_results_list.append(inference_result) + yield inference_result + except Exception as e: + set_error = e + raise + finally: + await self._callbacks_runner.run_after_inference_set( + inference_request, + inference_results_list, + set_error, + start_time, + run_ctx, + ) + + @override + async def evaluate( + self, + evaluate_request: EvaluateRequest, + ) -> AsyncGenerator[EvalCaseResult, None]: + """Evaluate inference results. + + Args: + evaluate_request: The evaluation request + + Yields: + EvalCaseResult for each evaluated inference + """ + # Fail-fast: validate every (eval_case × metric) pair is semantically + # feasible before running any evaluator. See Bug 3.1 design doc. + self._validate_metric_compat( + inference_results=evaluate_request.inference_results, + evaluate_config=evaluate_request.evaluate_config, + ) + + run_ctx: dict[str, Any] = {} + start_time = time.monotonic() + eval_case_results_list: list[EvalCaseResult] = [] + set_error: Optional[Exception] = None + ir0 = evaluate_request.inference_results[0] if evaluate_request.inference_results else None + app_name = ir0.app_name if ir0 else "" + eval_set_id = ir0.eval_set_id if ir0 else "" + + await self._callbacks_runner.run_before_evaluate_set(evaluate_request, run_ctx) + + semaphore = asyncio.Semaphore(value=evaluate_request.evaluate_config.parallelism) + + async def run_one_eval(inference_result: InferenceResult, ) -> tuple[InferenceResult, EvalCaseResult]: + case_ctx = run_ctx.copy() + await self._callbacks_runner.run_before_evaluate_case(evaluate_request, inference_result.eval_case_id, + case_ctx) + case_start = time.monotonic() + async with semaphore: + inference_result, eval_case_result = await self._evaluate_single_inference_result( + inference_result=inference_result, + evaluate_config=evaluate_request.evaluate_config, + ) + await self._callbacks_runner.run_after_evaluate_case( + evaluate_request, + inference_result, + eval_case_result, + None, + case_start, + case_ctx, + ) + return (inference_result, eval_case_result) + + try: + tasks = [run_one_eval(ir) for ir in evaluate_request.inference_results] + for coro in asyncio.as_completed(tasks): + inference_result, eval_case_result = await coro + eval_case_results_list.append(eval_case_result) + yield eval_case_result + if self._eval_set_results_manager and eval_case_results_list and app_name: + sorted_results = sorted( + eval_case_results_list, + key=(lambda r: (r.run_id or 0, r.eval_id)), + ) + self._eval_set_results_manager.save_eval_set_result( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_results=sorted_results, + ) + except Exception as e: + set_error = e + raise + finally: + await self._callbacks_runner.run_after_evaluate_set( + evaluate_request, + EvalSetRunResult( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_results=eval_case_results_list, + ), + set_error, + start_time, + run_ctx, + ) + + async def _evaluate_single_inference_result( + self, + inference_result: InferenceResult, + evaluate_config: EvaluateConfig, + ) -> tuple[InferenceResult, EvalCaseResult]: + """Evaluate a single inference result. + + Args: + inference_result: The inference result to evaluate + evaluate_config: Evaluation configuration + + Returns: + Tuple of (InferenceResult, EvalCaseResult) + """ + # Get expected invocations from the eval case + eval_case = self._eval_sets_manager.get_eval_case( + app_name=inference_result.app_name, + eval_set_id=inference_result.eval_set_id, + eval_case_id=inference_result.eval_case_id, + ) + + if eval_case is None: + raise ValueError(f"Eval case with id {inference_result.eval_case_id} not found for " + f"app {inference_result.app_name} and eval set " + f"{inference_result.eval_set_id}.") + + expected_invocations = self._build_expected_invocations_for_eval(eval_case) + + user_id, _ = self._get_user_id_from_eval_case(eval_case) + session_app_name, session_app_name_set = self._get_session_app_name(eval_case, inference_result.app_name) + # When user passed runner and case did not set app_name, use runner.app_name (same as inference) + if self._runner is not None and not session_app_name_set: + session_app_name = self._runner.app_name + + # Initialize result structures + eval_metric_result_per_invocation = [] + overall_eval_metric_results = [] + + # Pre-create EvalMetricResults entries for each invocation + if inference_result.inferences: + for idx, actual in enumerate(inference_result.inferences): + expected = None + if expected_invocations and idx < len(expected_invocations): + expected = expected_invocations[idx] + + eval_metric_result_per_invocation.append( + EvalMetricResultPerInvocation( + actual_invocation=actual, + expected_invocation=expected, + eval_metric_results=[], + )) + + # Evaluate each metric + case_error_message: Optional[str] = None + for eval_metric in evaluate_config.eval_metrics: + try: + evaluation_result = await self._evaluate_metric( + eval_metric=eval_metric, + actual_invocations=inference_result.inferences or [], + expected_invocations=expected_invocations, + ) + except Exception as e: # pylint: disable=broad-except + if case_error_message is None: + case_error_message = str(e) + log_error( + "Metric evaluation failed for metric `%s` for eval case id '%s'" + " with following error `%s`", + eval_metric.metric_name, + inference_result.eval_case_id, + e, + exc_info=True, + ) + evaluation_result = EvaluationResult(overall_eval_status=EvalStatus.NOT_EVALUATED) + + # Track overall score + reasons = [] + overall_rubric_scores = [] + for pr in evaluation_result.per_invocation_results: + if pr.reason is not None: + reasons.append(pr.reason) + if pr.rubric_scores: + overall_rubric_scores.extend(pr.rubric_scores) + overall_reason = ";".join(reasons) if reasons else None + overall_rubric = overall_rubric_scores if overall_rubric_scores else None + overall_score = evaluation_result.overall_score + overall_eval_metric_results.append( + EvalMetricResult( + score=overall_score, + eval_status=evaluation_result.overall_eval_status, + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + details=EvalMetricResultDetails( + reason=overall_reason, + score=overall_score, + rubric_scores=overall_rubric, + ) if (overall_reason is not None or overall_rubric is not None) else None, + )) + + # Track per-invocation scores + for idx, invocation in enumerate(eval_metric_result_per_invocation): + if idx < len(evaluation_result.per_invocation_results): + invocation_result = evaluation_result.per_invocation_results[idx] + else: + invocation_result = PerInvocationResult(actual_invocation=invocation.actual_invocation) + inv_score = invocation_result.score + invocation.eval_metric_results.append( + EvalMetricResult( + score=inv_score, + eval_status=invocation_result.eval_status, + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + details=EvalMetricResultDetails( + reason=invocation_result.reason, + score=inv_score, + rubric_scores=invocation_result.rubric_scores, + ) if + (invocation_result.reason is not None or invocation_result.rubric_scores is not None) else None, + )) + + # Determine final status + final_eval_status = self._generate_final_eval_status(overall_eval_metric_results) + + # Get session from same service as at inference (runner.session_service when user passed runner) + session_service = (self._runner.session_service if self._runner is not None else self._session_service) + session_details = await session_service.get_session( + app_name=session_app_name, + user_id=user_id, + session_id=inference_result.session_id, + ) + + # Create result + eval_case_result = EvalCaseResult( + eval_set_id=inference_result.eval_set_id, + eval_id=inference_result.eval_case_id, + run_id=getattr(inference_result, "run_id", None), + final_eval_status=final_eval_status, + error_message=case_error_message, + overall_eval_metric_results=overall_eval_metric_results, + eval_metric_result_per_invocation=eval_metric_result_per_invocation, + session_id=inference_result.session_id or "", + session_details=session_details, + user_id=user_id, + ) + + return (inference_result, eval_case_result) + + async def _evaluate_metric( + self, + eval_metric: EvalMetric, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + """Evaluate a metric using the appropriate evaluator. + + Args: + eval_metric: The metric to evaluate + actual_invocations: Actual invocations from the agent + expected_invocations: Expected invocations (optional) + + Returns: + EvaluationResult with scores + """ + evaluator = self._evaluator_registry.get_evaluator(eval_metric) + + if inspect.iscoroutinefunction(evaluator.evaluate_invocations): + return await evaluator.evaluate_invocations( + actual_invocations=actual_invocations, + expected_invocations=expected_invocations, + ) + else: + return evaluator.evaluate_invocations( + actual_invocations=actual_invocations, + expected_invocations=expected_invocations, + ) + + def _validate_metric_compat( + self, + inference_results: list[InferenceResult], + evaluate_config: EvaluateConfig, + ) -> None: + """Fail-fast check every (eval_case × metric) pair for semantic feasibility. + + Reference-based metrics (``requires_reference=True``) need a real + expected answer in ``eval_case.conversation``. Scenario-3 trace cases + (only ``actual_conversation`` set) have placeholder-only expected + invocations, which would produce silent 0.0 (rouge/final_response) or + phantom 1.0 (tool_trajectory with subset_matching) results. + + This method aggregates ALL incompatible pairs across the request and + raises a single ValueError listing each one, so the user can fix them + in one pass. See Bug 3.1 of the strict-compat design doc. + """ + incompatible: list[tuple[str, str, str]] = [] + for ir in inference_results: + eval_case = self._eval_sets_manager.get_eval_case( + app_name=ir.app_name, + eval_set_id=ir.eval_set_id, + eval_case_id=ir.eval_case_id, + ) + if eval_case is None: + # The per-case evaluate path will raise a clear error later; + # skip here to avoid duplicate / misleading messages. + continue + has_reference = self._case_has_reference(eval_case) + for eval_metric in evaluate_config.eval_metrics: + try: + evaluator_cls = self._evaluator_registry.get_evaluator_class(eval_metric) + except ValueError: + # Unknown metric — let the regular evaluate loop surface the + # registry error so the message stays consistent. + continue + if getattr(evaluator_cls, "requires_reference", True) and not has_reference: + incompatible.append(( + ir.eval_case_id, + eval_metric.metric_name, + "requires reference answer (set in eval_case.conversation)", + )) + + if incompatible: + raise ValueError(self._format_compat_error(incompatible)) + + @staticmethod + def _case_has_reference(eval_case: EvalCase) -> bool: + """True iff ``expected_invocations`` will carry a real reference answer. + + - Non-trace (``eval_mode is None``): ``conversation`` IS the expected → True. + - Scenario 1 (trace + both conversation and actual_conversation): True. + - Scenario 3 (trace + only actual_conversation): expected is a placeholder + built by ``_trace_expecteds_for_eval`` → False. + """ + if eval_case.eval_mode != EvalModeTrace: + return True + return bool(eval_case.conversation and eval_case.actual_conversation) + + @staticmethod + def _format_compat_error(incompatible: list[tuple[str, str, str]]) -> str: + """Aggregate incompatible pairs into a single actionable error message. + + Groups by eval_case_id for readability; always closes with a fix guide. + """ + by_case: dict[str, list[tuple[str, str]]] = {} + for eval_id, metric_name, reason in incompatible: + by_case.setdefault(eval_id, []).append((metric_name, reason)) + + lines = ["evaluator config incompatible with eval_set:", ""] + for eval_id in sorted(by_case): + lines.append(f" eval_case='{eval_id}' (scenario: trace-without-reference):") + for metric_name, reason in by_case[eval_id]: + lines.append(f" - metric '{metric_name}' {reason}") + lines.extend([ + "", + "To fix, choose one:", + " (a) Remove the incompatible metrics from your EvaluateConfig.", + " (b) Use reference-free metrics only: llm_rubric_response, " + "llm_rubric_knowledge_recall.", + " (c) Upgrade eval_cases to scenario-1 by providing BOTH `conversation` " + "(expected) and `actual_conversation` (actual) fields.", + "", + f"Incompatible (metric_name, eval_id) pairs: {len(incompatible)}", + ]) + return "\n".join(lines) + + def _generate_final_eval_status(self, overall_eval_metric_results: list[EvalMetricResult]) -> EvalStatus: + """Determine final evaluation status from all metrics. + + Args: + overall_eval_metric_results: Results for all metrics + + Returns: + Final evaluation status + """ + final_eval_status = EvalStatus.NOT_EVALUATED + + for result in overall_eval_metric_results: + if result.eval_status == EvalStatus.PASSED: + final_eval_status = EvalStatus.PASSED + elif result.eval_status == EvalStatus.FAILED: + return EvalStatus.FAILED # Any failure means overall failure + + return final_eval_status + + async def _perform_inference_single_eval_item( + self, + app_name: str, + eval_set_id: str, + eval_case: EvalCase, + root_agent: BaseAgent, + session_id: Optional[str] = None, + ) -> InferenceResult: + """Perform inference for a single eval case. + + Args: + app_name: Application name + eval_set_id: Eval set ID + eval_case: The eval case to run + root_agent: The agent to evaluate + session_id: Optional session ID (e.g. from BeforeInferenceCase callback context) + + Returns: + InferenceResult with generated invocations + """ + if session_id is None: + session_id = self._session_id_supplier() + inference_result = InferenceResult( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_id=eval_case.eval_id, + session_id=session_id, + ) + + try: + if eval_case.eval_mode == EvalModeTrace: + inferences = self._inference_trace_mode(eval_case, session_id) + else: + if eval_case.actual_conversation: + raise ValueError( + f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + "actual_conversation is only supported in trace mode") + if root_agent is None: + raise ValueError(f"inference eval case (eval_case_id={eval_case.eval_id}, " + f"session_id={session_id}): a root_agent is required for " + f"standard (non-trace) eval_mode; got root_agent=None") + inferences = await self._generate_inferences_from_agent( + agent=root_agent, + eval_case=eval_case, + session_id=session_id, + app_name=app_name, + ) + + inference_result.inferences = inferences + inference_result.status = InferenceStatus.SUCCESS + + return inference_result + except Exception as ex: # pylint: disable=broad-except + log_error( + "Inference failed for eval case `%s` with error %s.", + eval_case.eval_id, + ex, + exc_info=True, + ) + inference_result.status = InferenceStatus.FAILURE + inference_result.error_message = str(ex) + return inference_result + + def _inference_trace_mode(self, eval_case: EvalCase, session_id: str) -> list[Invocation]: + """Use pre-recorded conversation as inference result (trace mode). No agent run.""" + if eval_case.actual_conversation: + if eval_case.conversation and len(eval_case.actual_conversation) != len(eval_case.conversation): + raise ValueError(f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + f"actual_conversation length {len(eval_case.actual_conversation)} does not match " + f"conversation length {len(eval_case.conversation)}") + for i, inv in enumerate(eval_case.actual_conversation): + if inv is None: + raise ValueError( + f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + f"actual_conversation invocation is nil at index {i}") + if inv.user_content is None: + raise ValueError( + f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + f"actual_conversation invocation user_content is nil at index {i}") + return list(eval_case.actual_conversation) + if not eval_case.conversation: + raise ValueError(f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + "trace mode invocations are empty") + return list(eval_case.conversation) + + def _trace_expecteds_for_eval(self, conversation: list[Invocation]) -> list[Invocation]: + """Build placeholder expected invocations with only user input (trace mode, no reference answer).""" + result = [] + for inv in conversation: + if inv is None: + result.append( + Invocation( + invocation_id="", + user_content=Content(parts=[]), + final_response=None, + intermediate_data=None, + creation_timestamp=0.0, + )) + else: + result.append( + Invocation( + invocation_id=inv.invocation_id, + user_content=inv.user_content, + final_response=None, + intermediate_data=None, + creation_timestamp=inv.creation_timestamp, + )) + return result + + def _build_expected_invocations_for_eval(self, eval_case: EvalCase) -> Optional[list[Invocation]]: + """Build expected invocations for evaluation. Trace mode: conversation as expected or placeholders.""" + if eval_case.eval_mode == EvalModeTrace: + if eval_case.conversation: + if eval_case.actual_conversation: + return list(eval_case.conversation) + return self._trace_expecteds_for_eval(eval_case.conversation) + if eval_case.actual_conversation: + return self._trace_expecteds_for_eval(eval_case.actual_conversation) + return None + if eval_case.conversation: + return eval_case.conversation + return None + + async def _generate_inferences_from_agent( + self, + agent: BaseAgent, + eval_case: EvalCase, + session_id: str, + app_name: str, + ) -> list[Invocation]: + """Generate invocations from an agent for an eval case. + + Args: + agent: The agent to run + eval_case: The eval case + session_id: Session ID + app_name: Application name (used for session storage) + + Returns: + List of generated invocations + """ + # Use UserSimulatorProvider to get the appropriate simulator + user_simulator = self._user_simulator_provider.provide(eval_case) + + user_id, _ = self._get_user_id_from_eval_case(eval_case) + initial_state, _ = self._get_initial_state_from_eval_case(eval_case) + session_app_name, session_app_name_set = self._get_session_app_name(eval_case, app_name) + + if self._runner is not None: + runner = self._runner + if eval_case.context_messages: + runner = Runner( + app_name=runner.app_name, + agent=runner.agent, + session_service=EvalSessionService( + runner.session_service, + context_messages=eval_case.context_messages, + ), + artifact_service=runner.artifact_service, + memory_service=runner.memory_service, + ) + else: + runner = Runner( + app_name=session_app_name, + agent=agent, + session_service=EvalSessionService( + self._session_service, + context_messages=eval_case.context_messages, + ), + artifact_service=self._artifact_service, + memory_service=self._memory_service, + ) + + if eval_case.session_input is not None or eval_case.context_messages: + # When user passes runner and case does not set app_name, use runner.app_name + app_name_for_session = (runner.app_name if + (self._runner is not None and not session_app_name_set) else session_app_name) + await runner.session_service.create_session( + app_name=app_name_for_session, + user_id=user_id, + session_id=session_id, + state=initial_state, + ) + + # Run conversation + invocations = [] + agent_context = new_agent_context() + + while True: + # Get next user message + events_so_far = [] + next_message_result = await user_simulator.get_next_user_message(events_so_far) + + if next_message_result.status != Status.SUCCESS: + break + + user_message = next_message_result.user_message + + # Run agent + events = [] + final_response = None + intermediate_events = [] + tool_uses = [] + tool_responses = [] + intermediate_responses = [] + + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=user_message, + agent_context=agent_context, + ): + events.append(event) + + # Collect intermediate events + if not event.is_final_response(): + intermediate_events.append(InvocationEvent( + author=event.author, + content=event.content, + )) + + # Extract tool calls and responses from event content + if event.content and event.content.parts: + for part in event.content.parts: + if part.function_call: + tool_uses.append(part.function_call) + if part.function_response: + tool_responses.append(part.function_response) + # Collect intermediate text responses + if part.text and event.author: + intermediate_responses.append((event.author, [part])) + else: + if event.content and event.content.parts: + if any(part.text for part in event.content.parts if part.text): + final_response = event.content + + # Create invocation with IntermediateData + intermediate_data = IntermediateData( + tool_uses=tool_uses, + tool_responses=tool_responses, + intermediate_responses=intermediate_responses, + ) + + invocation = Invocation( + invocation_id=f"inv_{len(invocations)}", + user_content=user_message, + final_response=final_response, + intermediate_data=intermediate_data, + creation_timestamp=time.time(), + ) + + invocations.append(invocation) + + return invocations diff --git a/trpc_agent_sdk/evaluation/_local_eval_set_results_manager.py b/trpc_agent_sdk/evaluation/_local_eval_set_results_manager.py new file mode 100644 index 000000000..10c1b1348 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_local_eval_set_results_manager.py @@ -0,0 +1,161 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Local file system based eval set results manager. + +""" + +from __future__ import annotations + +import json +import os +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.log import info as log_info + +from ._eval_result import EvalCaseResult +from ._eval_result import EvalSetResult +from ._eval_set_results_manager_base import EvalSetResultsManager +from ._eval_set_results_manager_utils import create_eval_set_result + +_TRPC_EVAL_HISTORY_DIR = ".trpc/eval_history" +_EVAL_SET_RESULT_FILE_EXTENSION = ".evalset_result.json" + + +class LocalEvalSetResultsManager(EvalSetResultsManager): + """An EvalSetResult manager that stores eval set results locally on disk. + + Results are saved to: {agents_dir}/.trpc/eval_history/{app_name}/ + Or when eval_history_dir is set: {eval_history_dir}/{app_name}/ + """ + + def __init__( + self, + agents_dir: str = "", + eval_history_dir: Optional[str] = None, + ): + """Initialize the local eval set results manager. + + Args: + agents_dir: Base directory where agents are stored (used when + eval_history_dir is not set). + eval_history_dir: When set, result files are written under this + directory (with app_name subdir), ignoring agents_dir for paths. + """ + self._agents_dir = agents_dir + self._eval_history_dir = eval_history_dir + + @override + def save_eval_set_result( + self, + app_name: str, + eval_set_id: str, + eval_case_results: list[EvalCaseResult], + ) -> None: + """Creates and saves a new EvalSetResult given eval_case_results. + + Args: + app_name: Name of the application + eval_set_id: ID of the eval set + eval_case_results: List of eval case results + """ + eval_set_result = create_eval_set_result(app_name, eval_set_id, eval_case_results) + + # Write eval result file, with eval_set_result_name. + app_eval_history_dir = self._get_eval_history_dir(app_name) + if not os.path.exists(app_eval_history_dir): + os.makedirs(app_eval_history_dir) + + # Convert to json and write to file. + eval_set_result_json = eval_set_result.model_dump_json() + eval_set_result_file_path = os.path.join( + app_eval_history_dir, + eval_set_result.eval_set_result_name + _EVAL_SET_RESULT_FILE_EXTENSION, + ) + + log_info("Writing eval result to file: %s", eval_set_result_file_path) + with open(eval_set_result_file_path, "w", encoding="utf-8") as f: + f.write(json.dumps(json.loads(eval_set_result_json), indent=2)) + + @override + def get_eval_set_result(self, app_name: str, eval_set_result_id: str) -> EvalSetResult: + """Returns an EvalSetResult identified by app_name and eval_set_result_id. + + Args: + app_name: Name of the application + eval_set_result_id: ID of the eval set result + + Returns: + EvalSetResult for the given IDs + + Raises: + FileNotFoundError: If the eval set result file is not found. + """ + # Load the eval set result file data. + maybe_eval_result_file_path = (os.path.join( + self._get_eval_history_dir(app_name), + eval_set_result_id, + ) + _EVAL_SET_RESULT_FILE_EXTENSION) + + if not os.path.exists(maybe_eval_result_file_path): + raise FileNotFoundError(f"Eval set result `{eval_set_result_id}` not found at " + f"{maybe_eval_result_file_path}") + + with open(maybe_eval_result_file_path, "r", encoding="utf-8") as file: + eval_result_data = json.load(file) + + return EvalSetResult.model_validate_json(json.dumps(eval_result_data)) + + @override + def list_eval_set_results(self, app_name: str) -> list[str]: + """Returns the eval result ids that belong to the given app_name. + + Args: + app_name: Name of the application + + Returns: + List of eval set result IDs + """ + app_eval_history_directory = self._get_eval_history_dir(app_name) + + if not os.path.exists(app_eval_history_directory): + return [] + + eval_result_files = [ + file.removesuffix(_EVAL_SET_RESULT_FILE_EXTENSION) for file in os.listdir(app_eval_history_directory) + if file.endswith(_EVAL_SET_RESULT_FILE_EXTENSION) + ] + return eval_result_files + + def _get_eval_history_dir(self, app_name: str) -> str: + """Get the eval history directory for the given app. + + Args: + app_name: Name of the application + + Returns: + Path to the eval history directory + """ + if self._eval_history_dir is not None: + return os.path.join(self._eval_history_dir, app_name) + return os.path.join(self._agents_dir, _TRPC_EVAL_HISTORY_DIR, app_name) diff --git a/trpc_agent_sdk/evaluation/_local_eval_sets_manager.py b/trpc_agent_sdk/evaluation/_local_eval_sets_manager.py new file mode 100644 index 000000000..cf0726e82 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_local_eval_sets_manager.py @@ -0,0 +1,251 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Local evaluation sets manager that stores eval sets on disk.""" + +from __future__ import annotations + +import json +import os +import re +import time +from typing import Optional +from typing_extensions import override + +from pydantic import ValidationError + +from trpc_agent_sdk.log import error as log_error +from trpc_agent_sdk.log import info as log_info +from trpc_agent_sdk.log import warning as log_warning + +from ._eval_case import EvalCase +from ._eval_set import EvalSet +from ._eval_sets_manager_base import EvalSetsManager +from ._eval_sets_manager_utils import add_eval_case_to_eval_set +from ._eval_sets_manager_utils import delete_eval_case_from_eval_set +from ._eval_sets_manager_utils import get_eval_case_from_eval_set +from ._eval_sets_manager_utils import get_eval_set_from_app_and_id +from ._eval_sets_manager_utils import update_eval_case_in_eval_set + +_EVAL_SET_FILE_EXTENSION = ".evalset.json" + + +def load_eval_set_from_file(eval_set_file_path: str, eval_set_id: str) -> EvalSet: + """Returns an EvalSet that is read from the given file.""" + with open(eval_set_file_path, "r", encoding="utf-8") as f: + content = f.read() + try: + return EvalSet.model_validate_json(content) + except ValidationError: + # Try to load as old format (list of eval cases) + try: + old_format_data = json.loads(content) + if isinstance(old_format_data, list): + # Convert old format to new format + eval_cases = [] + for old_case in old_format_data: + # Convert old format eval case to new format + # This is a simplified conversion - may need adjustment + eval_case = EvalCase( + eval_id=old_case.get("name", f"case_{len(eval_cases)}"), + conversation=[], + session_input=None, + creation_timestamp=time.time(), + ) + eval_cases.append(eval_case) + + return EvalSet( + eval_set_id=eval_set_id, + name=eval_set_id, + eval_cases=eval_cases, + creation_timestamp=time.time(), + ) + except Exception as ex: # pylint: disable=broad-except + log_error("Failed to load eval set from %s: %s", eval_set_file_path, ex) + raise ValueError(f"Failed to load eval set from {eval_set_file_path}: {ex}") from ex + + +class LocalEvalSetsManager(EvalSetsManager): + """An EvalSets manager that stores eval sets locally on disk.""" + + def __init__(self, agents_dir: str): + """Initialize the local eval sets manager. + + Args: + agents_dir: Base directory where agents are stored + """ + self._agents_dir = agents_dir + + @override + def get_eval_set(self, app_name: str, eval_set_id: str) -> Optional[EvalSet]: + """Returns an EvalSet identified by an app_name and eval_set_id.""" + # Try multiple possible paths + possible_paths = [ + # Standard path: {agents_dir}/{app_name}/{eval_set_id}.evalset.json + os.path.join( + self._agents_dir, + app_name, + eval_set_id + _EVAL_SET_FILE_EXTENSION, + ), + ] + + for eval_set_file_path in possible_paths: + if os.path.exists(eval_set_file_path): + try: + return load_eval_set_from_file(eval_set_file_path, eval_set_id) + except Exception as ex: # pylint: disable=broad-except + log_warning("Failed to load eval set from %s: %s", eval_set_file_path, ex) + continue + + return None + + @override + def create_eval_set(self, app_name: str, eval_set_id: str) -> EvalSet: + """Creates and returns an empty EvalSet given the app_name and eval_set_id. + + Raises: + ValueError: If Eval Set ID is not valid or an eval set already exists. + """ + self._validate_id(id_name="Eval Set ID", id_value=eval_set_id) + + # Use standard path: {agents_dir}/{app_name}/{eval_set_id}.evalset.json + new_eval_set_path = os.path.join( + self._agents_dir, + app_name, + eval_set_id + _EVAL_SET_FILE_EXTENSION, + ) + + log_info("Creating eval set file `%s`", new_eval_set_path) + + if not os.path.exists(new_eval_set_path): + # Write the JSON string to the file + log_info("Eval set file doesn't exist, we will create a new one.") + new_eval_set = EvalSet( + eval_set_id=eval_set_id, + name=eval_set_id, + eval_cases=[], + creation_timestamp=time.time(), + ) + self._write_eval_set_to_path(new_eval_set_path, new_eval_set) + return new_eval_set + + raise ValueError(f"EvalSet {eval_set_id} already exists for app {app_name}.") + + @override + def list_eval_sets(self, app_name: str) -> list[str]: + """Returns a list of EvalSets that belong to the given app_name. + + Args: + app_name: The app name to list the eval sets for. + + Returns: + A list of EvalSet ids. + """ + eval_sets = [] + + # Try standard path first: {agents_dir}/{app_name}/ + app_dir = os.path.join(self._agents_dir, app_name) + if os.path.exists(app_dir): + try: + for file in os.listdir(app_dir): + if file.endswith(_EVAL_SET_FILE_EXTENSION): + eval_set_id = file.removesuffix(_EVAL_SET_FILE_EXTENSION) + eval_sets.append(eval_set_id) + except Exception as ex: # pylint: disable=broad-except + log_warning("Failed to list eval sets from %s: %s", app_dir, ex) + + return sorted(eval_sets) + + @override + def get_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str) -> Optional[EvalCase]: + """Returns an EvalCase if found; otherwise, None.""" + eval_set = self.get_eval_set(app_name, eval_set_id) + if not eval_set: + return None + return get_eval_case_from_eval_set(eval_set, eval_case_id) + + @override + def add_eval_case(self, app_name: str, eval_set_id: str, eval_case: EvalCase): + """Adds the given EvalCase to an existing EvalSet identified by app_name and eval_set_id. + + Raises: + NotFoundError: If the eval set is not found. + """ + eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id) + updated_eval_set = add_eval_case_to_eval_set(eval_set, eval_case) + + self._save_eval_set(app_name, eval_set_id, updated_eval_set) + + @override + def update_eval_case(self, app_name: str, eval_set_id: str, updated_eval_case: EvalCase): + """Updates an existing EvalCase given the app_name and eval_set_id. + + Raises: + NotFoundError: If the eval set or the eval case is not found. + """ + eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id) + updated_eval_set = update_eval_case_in_eval_set(eval_set, updated_eval_case) + self._save_eval_set(app_name, eval_set_id, updated_eval_set) + + @override + def delete_eval_case(self, app_name: str, eval_set_id: str, eval_case_id: str): + """Deletes the given EvalCase identified by app_name, eval_set_id and eval_case_id. + + Raises: + NotFoundError: If the eval set or the eval case to delete is not found. + """ + eval_set = get_eval_set_from_app_and_id(self, app_name, eval_set_id) + updated_eval_set = delete_eval_case_from_eval_set(eval_set, eval_case_id) + self._save_eval_set(app_name, eval_set_id, updated_eval_set) + + def _get_eval_set_file_path(self, app_name: str, eval_set_id: str) -> str: + """Get the file path for an eval set. + + Uses standard path: {agents_dir}/{app_name}/{eval_set_id}.evalset.json + """ + return os.path.join( + self._agents_dir, + app_name, + eval_set_id + _EVAL_SET_FILE_EXTENSION, + ) + + def _validate_id(self, id_name: str, id_value: str): + """Validate an ID format.""" + pattern = r"^[a-zA-Z0-9_]+$" + if not bool(re.fullmatch(pattern, id_value)): + raise ValueError(f"Invalid {id_name}. {id_name} should have the `{pattern}` format", ) + + def _write_eval_set_to_path(self, eval_set_path: str, eval_set: EvalSet): + """Write an eval set to a file path.""" + os.makedirs(os.path.dirname(eval_set_path), exist_ok=True) + with open(eval_set_path, "w", encoding="utf-8") as f: + f.write(eval_set.model_dump_json( + indent=2, + exclude_unset=True, + exclude_defaults=True, + exclude_none=True, + )) + + def _save_eval_set(self, app_name: str, eval_set_id: str, eval_set: EvalSet): + """Save an eval set to disk.""" + eval_set_file_path = self._get_eval_set_file_path(app_name, eval_set_id) + self._write_eval_set_to_path(eval_set_file_path, eval_set) diff --git a/trpc_agent_sdk/evaluation/_optimize_config.py b/trpc_agent_sdk/evaluation/_optimize_config.py new file mode 100644 index 000000000..31547125a --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_config.py @@ -0,0 +1,257 @@ +# 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. +"""Optimizer configuration schema. + +Each registered algorithm contributes a pydantic model under +``OptimizeConfig.algorithm``; field names mirror the upstream library +(e.g. https://github.com/gepa-ai/gepa) 1:1 so users can cross-reference +upstream docs without translating. + +The top-level ``optimize`` section only carries algorithm-agnostic +switches (e.g. evaluator parallelism, framework stop policies); any +switch whose effect depends on the selected algorithm lives inside the +algorithm block. +""" + +from __future__ import annotations + +from typing import Literal +from typing import Optional +from typing import Union + +from pydantic import Field +from pydantic import model_validator + +from ._common import EvalBaseModel +from ._eval_config import EvalConfig +from ._optimize_model_options import OptimizeModelOptions + + +class GepaReflectiveAlgo(EvalBaseModel): + """gepa_reflective algorithm configuration. + + Field names mirror ``gepa.optimize`` parameters and gepa + ``StopperProtocol`` constructor arguments so config maps to gepa + docs directly. + """ + + name: Literal["gepa_reflective"] = Field(description="Algorithm discriminator tag.", ) + + seed: int = Field( + default=42, + description="Random seed forwarded to gepa.optimize(seed=...).", + ) + reflection_lm: OptimizeModelOptions = Field( + description=("LLM gepa uses to reflect on failed cases and propose new prompts. " + "Forwarded to gepa.optimize(reflection_lm=...)."), ) + + candidate_selection_strategy: Literal[ + "pareto", + "current_best", + "epsilon_greedy", + "top_k_pareto", + ] = Field( + default="pareto", + description="Strategy gepa uses to pick the parent candidate each round.", + ) + module_selector: str = Field( + default="round_robin", + description="Component selector passed to gepa (e.g. 'round_robin', 'all').", + ) + frontier_type: Literal["instance", "objective", "hybrid", "cartesian"] = Field( + default="instance", + description="Pareto frontier tracking granularity forwarded to gepa.", + ) + reflection_minibatch_size: Optional[int] = Field( + default=None, + description="Per-round minibatch size for the reflective dataset; None lets gepa decide.", + ) + reflection_history_top_k: int = Field( + default=2, + ge=0, + le=5, + description=("How many historical best traces per case to expose to the " + "reflection LM as the ``history_top_k`` record field. 0 " + "disables the feature. Capped at 5 to bound prompt-token " + "growth — for K=2 a typical multi-turn case grows ~30%."), + ) + perfect_score: float = Field( + default=1.0, + description="Score considered 'perfect' for skip_perfect_score decisions.", + ) + skip_perfect_score: bool = Field( + default=True, + description="Whether gepa skips optimizing instances that already score perfect.", + ) + + use_merge: bool = Field( + default=False, + description="Whether to enable gepa merge-based candidate proposals.", + ) + max_merge_invocations: int = Field( + default=5, + description="Maximum merge invocations when use_merge is true.", + ) + merge_val_overlap_floor: int = Field( + default=5, + description="Minimum shared validation ids required before attempting a merge subsample.", + ) + + cache_evaluation: bool = Field( + default=False, + description="Cache (candidate, case) scores so repeated evaluations skip the metric call.", + ) + track_best_outputs: bool = Field( + default=False, + description="Track per-case best outputs alongside the best candidate.", + ) + + max_metric_calls: Optional[int] = Field( + default=None, + description=("Stop after this many metric calls (one metric call = one case-level " + "evaluation). Mapped to gepa MaxMetricCallsStopper. At least one of the " + "five stop conditions on this object must be set."), + ) + max_iterations_without_improvement: Optional[int] = Field( + default=None, + description=("Stop after this many consecutive iterations whose best valset score " + "did not improve. Mapped to gepa NoImprovementStopper."), + ) + timeout_seconds: Optional[float] = Field( + default=None, + description=("Stop after this many wall-clock seconds. Mapped to gepa " + "TimeoutStopCondition."), + ) + score_threshold: Optional[float] = Field( + default=None, + description=("Stop once the best valset score reaches this threshold. Mapped to " + "gepa ScoreThresholdStopper."), + ) + max_candidate_proposals: Optional[int] = Field( + default=None, + description=("Stop after this many candidate proposals. Mapped to gepa " + "MaxCandidateProposalsStopper."), + ) + max_tracked_candidates: Optional[int] = Field( + default=None, + description=("Stop once the candidate pool reaches this size. Mapped to gepa " + "MaxTrackedCandidatesStopper."), + ) + + @model_validator(mode="after") + def _require_at_least_one_stop_condition(self) -> "GepaReflectiveAlgo": + if not any(value is not None for value in ( + self.max_metric_calls, + self.max_iterations_without_improvement, + self.timeout_seconds, + self.score_threshold, + self.max_candidate_proposals, + self.max_tracked_candidates, + )): + raise ValueError("gepa_reflective requires at least one stop condition: set one of " + "max_metric_calls / max_iterations_without_improvement / " + "timeout_seconds / score_threshold / max_candidate_proposals / " + "max_tracked_candidates.") + return self + + +class FrameworkStopConfig(EvalBaseModel): + """Framework-level stop policies applied to every algorithm. + + Today the only such policy is metric-based early stopping: stop + when every metric named by ``required_metrics`` meets its threshold + on the validation set. Threshold values come from + ``evaluate.metrics[].threshold``; this section only decides which + metrics participate. + + Pass-rate-based stopping is not exposed here because every supported + engine has an equivalent native field (e.g. ``algorithm.score_threshold`` + for gepa_reflective). + + Field values for ``required_metrics``: + - ``"all"`` (default): every metric in ``evaluate.metrics[]`` + must meet its threshold. + - ``list[str]``: only the listed metrics must meet thresholds. + Each name must match an entry in + ``evaluate.metrics[].metric_name`` (validated by + :class:`OptimizeConfigFile`). Empty list disables the policy. + - ``None``: disable the policy entirely; the run finishes only + via algorithm-native stop conditions. + """ + + required_metrics: Optional[Union[Literal["all"], list[str]]] = Field( + default="all", + description=("Metrics whose thresholds must be met on the validation set " + "before the framework asks the algorithm to stop. 'all' means " + "every metric in evaluate.metrics[]; a list narrows the set; " + "None or [] disables the policy."), + ) + + +class OptimizeConfig(EvalBaseModel): + """Algorithm-agnostic optimizer section. + + Holds switches the framework itself consumes; algorithm-specific + knobs live under :attr:`algorithm` so different algorithms can + expose entirely different field sets without polluting one another. + + To add a second algorithm: + 1. Define ``MyAlgo(EvalBaseModel)`` with ``name: Literal["my_algo"]``. + 2. Replace :attr:`algorithm` type with:: + + algorithm: Annotated[ + Union[GepaReflectiveAlgo, MyAlgo], + Field(discriminator="name"), + ] + + pydantic v2 then routes validation by the ``name`` tag and + rejects unknown algorithm names with a clear error. + """ + + eval_case_parallelism: int = Field( + default=4, + description="Case-level parallelism forwarded to the evaluator.", + ) + stop: FrameworkStopConfig = Field( + default_factory=FrameworkStopConfig, + description=("Framework-level stop policies; OR'd with any algorithm-native " + "stop conditions configured under :attr:`algorithm`."), + ) + algorithm: GepaReflectiveAlgo = Field(description="Algorithm selection and algorithm-specific parameters.", ) + + +class OptimizeConfigFile(EvalBaseModel): + """Top-level schema for an optimizer JSON config file.""" + + evaluate: EvalConfig = Field(description="Evaluator section: same schema as evaluator's EvalConfig.", ) + optimize: OptimizeConfig = Field(description="Optimizer section: framework switches plus the algorithm block.", ) + + @model_validator(mode="after") + def _validate_required_metrics_against_evaluate(self) -> "OptimizeConfigFile": + required = self.optimize.stop.required_metrics + if not isinstance(required, list) or not required: + return self + available = {metric.metric_name for metric in self.evaluate.get_eval_metrics()} + unknown = [name for name in required if name not in available] + if unknown: + raise ValueError("stop.required_metrics references unknown metric(s) " + f"{unknown}; available metrics from evaluate.metrics[]: " + f"{sorted(available)}") + return self + + +def load_optimize_config(path: str) -> OptimizeConfigFile: + """Load and parse an optimizer JSON config file. + + Accepts camelCase and snake_case keys. + + Raises: + FileNotFoundError: if path does not exist. + pydantic.ValidationError: on schema violations. + """ + with open(path, "r", encoding="utf-8") as f: + content = f.read() + return OptimizeConfigFile.model_validate_json(content) diff --git a/trpc_agent_sdk/evaluation/_optimize_evaluator_call.py b/trpc_agent_sdk/evaluation/_optimize_evaluator_call.py new file mode 100644 index 000000000..73b461488 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_evaluator_call.py @@ -0,0 +1,136 @@ +# 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. +"""Optimizer-facing wrapper around AgentEvaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from statistics import mean +from typing import Optional + +from ._agent_evaluator import AgentEvaluator +from ._agent_evaluator import _EvaluationCasesFailed +from ._eval_callbacks import Callbacks +from ._eval_metrics import EvalStatus +from ._eval_result import EvaluateResult +from ._remote_eval_service import CallAgent + + +@dataclass(frozen=True) +class EvaluationOutcome: + """Summary metrics extracted from an EvaluateResult for the optimizer. + + Attributes: + pass_rate: Fraction of cases whose final_eval_status is PASSED. + tiebreaker: Mean of all per-case metric scores; used when pass_rate ties. + metric_breakdown: Mean score per metric name across all cases. + failed_case_ids: Eval ids of cases that did not pass; duplicated across runs. + judge_model_calls: Currently always 0; the evaluator does not surface per-judge invocation counts. + raw_result: The original EvaluateResult for downstream inspection. + """ + + pass_rate: float + tiebreaker: float + metric_breakdown: dict[str, float] = field(default_factory=dict) + failed_case_ids: list[str] = field(default_factory=list) + judge_model_calls: int = 0 + raw_result: Optional[EvaluateResult] = None + + +def summarize_outcome(result: EvaluateResult) -> EvaluationOutcome: + """Reduce a raw EvaluateResult to the metrics the optimizer needs. + + judge_model_calls is set to 0 here; remote evaluators may overwrite it + after the call returns when actual judge invocation counts are known. + """ + total = 0 + passed = 0 + failed_case_ids: list[str] = [] + scores_by_metric: dict[str, list[float]] = {} + + for set_result in result.results_by_eval_set_id.values(): + for eval_id, runs in set_result.eval_results_by_eval_id.items(): + for run in runs: + total += 1 + if run.final_eval_status == EvalStatus.PASSED: + passed += 1 + else: + failed_case_ids.append(eval_id) + for metric in run.overall_eval_metric_results: + if metric.score is None: + continue + scores_by_metric.setdefault(metric.metric_name, []).append(metric.score) + + pass_rate = passed / total if total > 0 else 0.0 + metric_breakdown = {name: mean(scores) for name, scores in scores_by_metric.items()} + all_scores = [s for scores in scores_by_metric.values() for s in scores] + tiebreaker = mean(all_scores) if all_scores else 0.0 + + return EvaluationOutcome( + pass_rate=pass_rate, + tiebreaker=tiebreaker, + metric_breakdown=metric_breakdown, + failed_case_ids=failed_case_ids, + judge_model_calls=0, + raw_result=result, + ) + + +async def run_evaluator( + *, + eval_dataset_path: str, + eval_metrics_path: Optional[str], + call_agent: CallAgent, + callbacks: Optional[Callbacks], + num_runs: int = 1, + case_parallelism: Optional[int] = None, +) -> EvaluationOutcome: + """Run the evaluator over a dataset and summarize the outcome. + + Args: + eval_dataset_path: Path to an eval set file or directory of eval sets. + eval_metrics_path: Path to a shared metrics config file; None falls back to dataset-local config. + call_agent: Async function that maps a user query to an agent response. + callbacks: Optional lifecycle callbacks passed through to the evaluator. + num_runs: Number of runs per eval set. + case_parallelism: Max concurrent cases for inference; None lets the + evaluator use its default. Plumbs ``optimize.eval_case_parallelism`` + through to :meth:`AgentEvaluator.get_executer`. + + Returns: + EvaluationOutcome with extracted pass_rate / tiebreaker / metric_breakdown / failed_case_ids. + """ + executer = AgentEvaluator.get_executer( + eval_dataset_path, + call_agent=call_agent, + callbacks=callbacks, + num_runs=num_runs, + print_detailed_results=False, + print_summary_report=False, + eval_result_output_dir=None, + eval_metrics_file_path_or_dir=eval_metrics_path, + case_parallelism=case_parallelism, + ) + # _EvaluationCasesFailed signals "some cases failed" — the evaluator has + # already populated ``executer.get_result()`` before raising, so we swallow + # this specific subclass and let the optimizer keep iterating. Any other + # exception (FileNotFoundError, network error, third-party AssertionError, + # ...) is a real failure and must propagate: silently substituting an empty + # EvaluateResult would make the optimizer see a 0.0 pass_rate and continue + # optimizing against phantom data. + try: + await executer.evaluate() + except _EvaluationCasesFailed: + pass + result = executer.get_result() + if result is None: + # _run raised before populating self._result. This only happens on a + # real upstream error (which would have re-raised above) or a logic + # bug. Return an empty outcome rather than crash, but the path is + # defensive — not a normal control-flow branch. + result = EvaluateResult() + return summarize_outcome(result) diff --git a/trpc_agent_sdk/evaluation/_optimize_gepa_adapter.py b/trpc_agent_sdk/evaluation/_optimize_gepa_adapter.py new file mode 100644 index 000000000..34fc4dd2c --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_gepa_adapter.py @@ -0,0 +1,794 @@ +# 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. +"""GEPA protocol adapter and reflective-dataset builder. + +Implements ``gepa.core.adapter.GEPAAdapter`` so gepa's main loop can +drive evaluation through the framework's ``AgentEvaluator``. The +adapter stays decoupled from any specific gepa algorithm class so +gepa-family algorithms can reuse it without duplicating evaluator I/O. + +:meth:`_AgentGEPAAdapter.make_reflective_dataset` renders each failed +case into a turn-sliced markdown record +(``{case_id, score, "Case Body", "Other Active Components"?}``) tuned +for the reflection LM in multi-component / multi-turn / multi-run / +tool-using scenarios. + +``gepa`` is an optional dependency: ``EvaluationBatch`` is imported +lazily inside :meth:`_AgentGEPAAdapter.evaluate`, so importing this +module without ``gepa`` installed succeeds but ``evaluate`` then fails +fast. +""" + +from __future__ import annotations + +import asyncio +import tempfile +import uuid +from pathlib import Path +from typing import Any +from typing import Mapping +from typing import Optional +from typing import Sequence + +from ._eval_callbacks import Callbacks +from ._eval_case import EvalCase +from ._eval_case import Invocation +from ._eval_case import get_all_tool_calls +from ._eval_case import get_all_tool_responses +from ._eval_config import EvalConfig +from ._eval_metrics import EvalStatus +from ._eval_metrics import PrebuiltMetrics +from ._eval_result import EvalCaseResult +from ._eval_result import EvalMetricResult +from ._eval_result import EvaluateResult +from ._eval_set import EvalSet +from ._optimize_evaluator_call import run_evaluator +from ._remote_eval_service import CallAgent +from ._target_prompt import TargetPrompt + + +def _extract_case_output(case_result: EvalCaseResult) -> str: + """Return the agent's final response text from the first per-invocation entry. + + Used to populate ``EvaluationBatch.outputs`` — GEPA reads that field + directly to decide whether a candidate's behaviour improved on a case + even before consulting the trajectory or score. + """ + per_inv = case_result.eval_metric_result_per_invocation or [] + if not per_inv: + return "" + actual = per_inv[0].actual_invocation + if not actual or not actual.final_response or not actual.final_response.parts: + return "" + return "\n".join((p.text or "") for p in actual.final_response.parts).strip() + + +def _invocation_text(invocation: Optional[Invocation], *, user: bool) -> str: + """Concatenate a single invocation's user_content or final_response text.""" + if invocation is None: + return "" + content = invocation.user_content if user else invocation.final_response + if content is None or not content.parts: + return "" + return "\n".join((p.text or "") for p in content.parts).strip() + + +def _render_metric_lines(metrics: Sequence[EvalMetricResult]) -> list[str]: + """Render one block of per-metric verdict lines for a turn or aggregate. + + Drives both per-invocation blocks (``### Turn N``) inside + :func:`_build_turn_block` and the case-level aggregate block + (``### Overall``) inside :func:`_build_overall_block`. Each metric + occupies one ``[PASS|FAIL] name: score=..., threshold=...`` line; + optional ``reason`` and rubric sub-score lines are nested below it. + """ + lines: list[str] = [] + for metric in metrics: + status = _format_status(metric.eval_status) + score_str = f"{metric.score:.4f}" if metric.score is not None else "n/a" + lines.append(f"[{status}] {metric.metric_name}: " + f"score={score_str}, threshold={metric.threshold:.4f}") + + # ``details.reason`` is only populated by LLM-judged evaluators. + # For deterministic matchers, synthesize a one-line explanation + # from the criterion config so the reflection LM sees WHY the + # check failed. + explicit_reason = (metric.details.reason if (metric.details and metric.details.reason) else None) + if explicit_reason: + lines.append(f" reason: {explicit_reason}") + else: + synthesized = _synthesize_failure_reason(metric) + if synthesized: + lines.append(f" reason: {synthesized}") + + # Expand rubric sub-scores so the reflection LM can target the + # precise failing aspect instead of guessing. + rubric_scores = (getattr(metric.details, "rubric_scores", None) if metric.details else None) + if rubric_scores: + for rs in rubric_scores: + rid = (getattr(rs, "id", None) if not isinstance(rs, dict) else rs.get("id")) or "?" + rscore = (getattr(rs, "score", None) if not isinstance(rs, dict) else rs.get("score")) + rreason = (getattr(rs, "reason", "") if not isinstance(rs, dict) else rs.get("reason", "")) + if rscore is None: + continue + rs_status = "PASS" if float(rscore) >= 1.0 else "FAIL" + line = (f" · rubric[{rid}]: {rs_status} " + f"score={float(rscore):.2f}") + if rreason: + line += f" reason: {rreason}" + lines.append(line) + return lines + + +def _synthesize_failure_reason(metric: EvalMetricResult) -> Optional[str]: + """Synthesize a short failure explanation for deterministic metrics. + + Deterministic evaluators (e.g. ``_final_response_evaluator``) only + emit ``score`` + ``eval_status``; without this, the reflection LM + has to diff the agent's output against the reference itself to + guess why the match failed. Translate the criterion config into one + of: + + - "agent output not byte-equal to expected (case-sensitive)" (exact) + - "expected substring not contained in agent output (case-insensitive)" (contains) + - "agent output did not match expected regex" (regex) + - "JSON structural comparison failed" (json) + - "text-... AND JSON-..." when both checks are configured + + Returns ``None`` for non-deterministic metrics, currently-passing + metrics, and missing/malformed criterion configs. + """ + if metric.metric_name != PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + return None + if metric.score is None or float(metric.score) >= 1.0: + return None + criterion = metric.criterion or {} + if not isinstance(criterion, dict): + return None + fr = criterion.get("final_response") or criterion.get("finalResponse") + if not isinstance(fr, dict): + return None + + notes: list[str] = [] + text = fr.get("text") or fr.get("text_strategy") or fr.get("textStrategy") + if isinstance(text, dict) and not text.get("ignore"): + match = str(text.get("match") or text.get("match_strategy") or "exact").strip().lower() + case_ins = bool(text.get("case_insensitive") or text.get("caseInsensitive")) + case_tag = "case-insensitive" if case_ins else "case-sensitive" + if match == "exact": + notes.append(f"agent output not byte-equal to expected ({case_tag})") + elif match == "contains": + notes.append(f"expected substring not contained in agent output ({case_tag})") + elif match == "regex": + notes.append(f"agent output did not match expected regex ({case_tag})") + else: + notes.append(f"text match (mode={match}) failed ({case_tag})") + + json_cfg = fr.get("json") or fr.get("json_strategy") or fr.get("jsonStrategy") + if isinstance(json_cfg, dict) and not json_cfg.get("ignore"): + notes.append("JSON structural comparison failed") + + if not notes: + return None + return " AND ".join(notes) + + +def _format_status(status: Any) -> str: + """Render an EvalStatus as its name (PASSED/FAILED/...) — readable + to the reflection LM than the numeric ``.value``. + """ + name = getattr(status, "name", None) + if isinstance(name, str): + return name + return str(status) + + +def _per_metric_objective_scores(case_runs: Sequence[EvalCaseResult], ) -> dict[str, float]: + """Build the per-objective score map for one case. + + Each metric name maps to the mean of its ``score`` across runs. + GEPA uses this to maintain a per-objective Pareto frontier + independent of the aggregated case score — so a candidate that + dominates on one metric (e.g. rubric quality) survives even when + overall pass rates tie. Metrics with no signal across all runs are + skipped (they would taint the mean). + """ + sums: dict[str, float] = {} + counts: dict[str, int] = {} + for run in case_runs: + for metric in run.overall_eval_metric_results or []: + if metric.score is None: + continue + sums[metric.metric_name] = sums.get(metric.metric_name, 0.0) + float(metric.score) + counts[metric.metric_name] = counts.get(metric.metric_name, 0) + 1 + return {name: sums[name] / counts[name] for name in sums} + + +def _continuous_case_score(case_runs: Sequence[EvalCaseResult]) -> float: + """Compute case_score as the mean of per-metric continuous scores. + + Per run: average all ``EvalMetricResult.score`` values (each in + ``[0, 1]``). Across runs (``num_runs > 1``): average the per-run + scores. Continuous scoring lets gepa distinguish candidates that + share PASS/FAIL labels but differ in metric quality (e.g. one keeps + a rubric at 1.0 while another regresses to 0.33 — both still FAIL + overall but only one is strictly better). + """ + run_scores: list[float] = [] + for run in case_runs: + metrics = run.overall_eval_metric_results or [] + metric_scores = [float(m.score) for m in metrics if m.score is not None] + if metric_scores: + run_scores.append(sum(metric_scores) / len(metric_scores)) + else: + # Fallback to the binary PASS/FAIL signal when no per-metric scores + # are emitted (e.g. error path or evaluator that omits details). + run_scores.append(1.0 if run.final_eval_status == EvalStatus.PASSED else 0.0) + if not run_scores: + return 0.0 + return sum(run_scores) / len(run_scores) + + +def _format_tool_args(args: Any) -> str: + """Render a tool-call ``args`` dict inline as ``k=v, k=v``. + + Inline form keeps each tool call on one line; gepa's prompt_renderer + would otherwise expand each arg into its own ``###### key`` heading + and hit the H6 cap. + """ + if not isinstance(args, dict): + return repr(args) + parts: list[str] = [] + for key, value in args.items(): + if isinstance(value, str): + parts.append(f"{key}={value!r}") + elif isinstance(value, (int, float, bool)) or value is None: + parts.append(f"{key}={value}") + else: + parts.append(f"{key}={value!r}") + return ", ".join(parts) + + +def _format_tool_response(response: Any) -> str: + """Render a tool response inline; collapse single-key dicts to bare value.""" + if isinstance(response, dict): + if len(response) == 1: + value = next(iter(response.values())) + if isinstance(value, str): + return repr(value) + return str(value) + return "{" + _format_tool_args(response) + "}" + if isinstance(response, str): + return repr(response) + return str(response) + + +def _resolve_turn_metrics(run: EvalCaseResult, turn_idx: int, total_turns: int) -> list[EvalMetricResult]: + """Pick the verdict slice for one (run, turn). + + Multi-turn cases use ``eval_metric_result_per_invocation[i]. + eval_metric_results``. Single-turn cases sometimes leave that empty + and only populate ``overall_eval_metric_results`` — fall back so a + Turn 1 block still carries a verdict. + """ + per_inv = run.eval_metric_result_per_invocation or [] + if 0 <= turn_idx - 1 < len(per_inv): + pinv = per_inv[turn_idx - 1] + if pinv.eval_metric_results: + return list(pinv.eval_metric_results) + if total_turns == 1: + return list(run.overall_eval_metric_results or []) + return [] + + +def _build_turn_block( + case: EvalCase, + case_runs: Sequence[EvalCaseResult], + turn_idx: int, + total_turns: int, + is_multi_run: bool, +) -> str: + """Render one ``### Turn N`` block grouping user/expected/agent/tool/verdict. + + Conversational truth (User/Expected) is shared across runs and printed + first; for each run the actual agent_response, function-call trace, and + per-turn verdict follow. Multi-run cases nest each run under + ``#### Run N`` so the LM can attribute output variance to a specific + roll-out. + """ + lines: list[str] = [f"### Turn {turn_idx}"] + + convo = case.conversation or case.actual_conversation or [] + if 0 <= turn_idx - 1 < len(convo): + inv = convo[turn_idx - 1] + user_text = _invocation_text(inv, user=True) + if user_text: + lines.append(f"**User**: {user_text}") + expected_text = _invocation_text(inv, user=False) + if expected_text: + lines.append(f"**Expected**: {expected_text}") + + for ordinal, run in enumerate(case_runs, start=1): + run_id = getattr(run, "run_id", None) or ordinal + per_inv = run.eval_metric_result_per_invocation or [] + actual_inv: Optional[Invocation] = None + if 0 <= turn_idx - 1 < len(per_inv): + actual_inv = per_inv[turn_idx - 1].actual_invocation + + if is_multi_run: + lines.append("") + lines.append(f"#### Run {run_id}") + + if actual_inv is not None: + response_text = _invocation_text(actual_inv, user=False) + if response_text: + lines.append(f"**Agent Response**: {response_text}") + + tool_calls = get_all_tool_calls(actual_inv.intermediate_data) + tool_responses = get_all_tool_responses(actual_inv.intermediate_data) + if tool_calls or tool_responses: + lines.append("**Tool Trace**:") + resp_by_id: dict[str, Any] = {tr.id: tr for tr in tool_responses if tr.id} + consumed_ids: set[str] = set() + for tc in tool_calls: + args_inline = _format_tool_args(tc.args) if tc.args else "" + suffix = "" + if tc.id and tc.id in resp_by_id: + tr = resp_by_id[tc.id] + consumed_ids.add(tc.id) + suffix = f" → {_format_tool_response(tr.response)}" + id_tag = f" [id={tc.id}]" if tc.id else "" + lines.append(f"- {tc.name or ''}({args_inline}){suffix}{id_tag}") + # Surface tool_responses arriving without a paired call so + # the reflection LM doesn't miss out-of-band observations. + for tr in tool_responses: + if tr.id and tr.id in consumed_ids: + continue + id_tag = f" [id={tr.id}]" if tr.id else "" + lines.append(f"- (orphan response) {tr.name or ''} → " + f"{_format_tool_response(tr.response)}{id_tag}") + + verdict_metrics = _resolve_turn_metrics(run, turn_idx, total_turns) + if verdict_metrics: + run_tag = f", Run {run_id}" if is_multi_run else "" + lines.append(f"**Verdict** (Turn {turn_idx}{run_tag}):") + for verdict_line in _render_metric_lines(verdict_metrics): + lines.append(f" {verdict_line}") + + return "\n".join(lines) + + +def _build_overall_block(case_runs: Sequence[EvalCaseResult], is_multi_run: bool) -> str: + """Render the case-level aggregate verdict block. + + Single-run: ``### Overall (case-level aggregate)`` from the run's + ``overall_eval_metric_results``. Multi-run: ``### Overall (per-run + aggregate)`` with one sub-block per run, so the LM can spot which + runs failed without averaging through to a single mean. + """ + if is_multi_run: + lines: list[str] = ["### Overall (per-run aggregate)"] + for ordinal, run in enumerate(case_runs, start=1): + run_id = getattr(run, "run_id", None) or ordinal + lines.append(f"**Run {run_id}**:") + for verdict_line in _render_metric_lines(run.overall_eval_metric_results or []): + lines.append(f" {verdict_line}") + return "\n".join(lines) + + lines = ["### Overall (case-level aggregate)"] + if case_runs: + lines.extend(_render_metric_lines(case_runs[0].overall_eval_metric_results or [])) + return "\n".join(lines) + + +def _build_case_body(case: EvalCase, case_runs: Sequence[EvalCaseResult]) -> str: + """Build the per-turn-sliced markdown body of a failed case. + + Each turn is one ``### Turn N`` block bundling user / expected / + agent_response / Tool Trace / Verdict so each failing metric is + visually anchored to the turn that produced it. Multi-run cases nest + each run under ``#### Run N``. Multi-turn or multi-run cases close + with an ``### Overall`` aggregate. + + Returns an empty string when no usable turn data is available, so + the caller can decide whether to drop the record. + """ + if not case_runs: + return "" + + n_runs = len(case_runs) + is_multi_run = n_runs > 1 + + convo = case.conversation or case.actual_conversation or [] + if convo: + n_turns = len(convo) + else: + n_turns = max( + (len(run.eval_metric_result_per_invocation or []) for run in case_runs), + default=0, + ) + + if n_turns == 0: + return "" + + blocks: list[str] = [] + for turn_idx in range(1, n_turns + 1): + blocks.append(_build_turn_block(case, case_runs, turn_idx, n_turns, is_multi_run)) + + # Single-turn single-run cases skip the Overall block — Turn 1 + # already carries the only verdict that exists. + if n_turns > 1 or is_multi_run: + blocks.append(_build_overall_block(case_runs, is_multi_run)) + + return "\n\n".join(blocks) + + +def _build_other_active_components(candidate: dict[str, str], components_to_update: Sequence[str]) -> str: + """Render the prompt content of every candidate component NOT being + refined this round. + + GEPA fills ```` with only the prompt being rewritten, + but the evaluator's verdict was produced by the agent running with + ALL prompts. Surfacing the others as static context stops the LM + from regressing requirements already enforced elsewhere or + duplicating instructions. + + Returns an empty string when there is only one component or when + the others contain no text. + """ + targets = set(components_to_update) + others = {name: text for name, text in candidate.items() if name not in targets and text} + if not others: + return "" + lines: list[str] = [] + for name in sorted(others): + lines.append(f"### {name} (current)") + lines.append(others[name].rstrip()) + lines.append("") + return "\n".join(lines).rstrip() + + +def _build_trajectory_entry( + case: EvalCase, + score: float, + *, + case_runs: Sequence[EvalCaseResult] = (), + error_message: Optional[str] = None, +) -> dict[str, Any]: + """Bundle one case's evaluation artifacts for reflective dataset construction. + + ``score`` lets ``make_reflective_dataset`` filter to failed cases + without re-reading the runs. ``_case`` + ``_case_runs`` carry + everything the record builder needs to render the turn-sliced body. + On evaluator-error paths (no runs produced), ``error_message`` + surfaces a diagnostic in place of a Case Body. + """ + return { + "score": score, + "_case": case, + "_case_runs": list(case_runs), + "error_message": error_message, + } + + +def _make_return_type_checked_call_agent(call_agent: Any) -> Any: + """Wrap ``call_agent`` with a one-shot return-type check. + + Plain ``async def f(query): return 42`` passes + :func:`inspect.iscoroutinefunction`, so the broken return type is only + discovered when a metric tries to call ``.lower()`` / ``.strip()`` on the + int and produces an opaque ``AttributeError`` deep inside the metric path. + + The wrapper validates ``isinstance(result, str)`` on the first call only, + raising a clear ``TypeError`` that names the actual returned type. After + the first successful call subsequent invocations bypass the check, so the + overhead is a single boolean check on the first case and zero thereafter. + """ + checked = {"done": False} + + async def _checked(query: str) -> str: + result = await call_agent(query) + if not checked["done"]: + if not isinstance(result, str): + raise TypeError(f"call_agent must return str; got " + f"{type(result).__name__} (value={result!r}). " + f"This is checked once on the first invocation.") + checked["done"] = True + return result + + return _checked + + +class _AgentGEPAAdapter: + """GEPA protocol adapter bridging gepa.optimize() to the framework evaluator. + + Per ``evaluate`` call: + 1. Apply the proposed ``candidate`` to all registered ``TargetPrompt`` fields. + 2. Serialize ``batch`` and ``eval_config`` to a temp directory. + 3. Run ``run_evaluator`` (asyncio.run) and collect per-case pass + status + final response. + 4. Build an ``EvaluationBatch`` carrying scores, outputs, and + (optionally) trajectories used by reflective dataset construction. + + ``make_reflective_dataset`` then renders failed trajectories as + ``{component: [{case_id, score, "Case Body", "Other Active Components"?}, + ...]}`` for gepa's reflection prompt template. + """ + + # gepa's reflective proposer reads ``adapter.propose_new_texts`` + # directly; ``None`` signals "use gepa's default reflection LM path". + propose_new_texts = None + + def __init__( + self, + *, + target_prompt: TargetPrompt, + eval_config: EvalConfig, + call_agent: CallAgent, + callbacks: Optional[Callbacks] = None, + num_runs: int = 1, + case_parallelism: Optional[int] = None, + top_k_per_case: int = 2, + ) -> None: + self.target_prompt = target_prompt + self.eval_config = eval_config + # Wrap call_agent so the first call validates the return type and + # surfaces a clear TypeError on misuse (e.g. ``async def f(): return 42`` + # passes static signature checks but only blows up inside metrics). + # The check fires once; later calls bypass the wrapper. + self.call_agent = _make_return_type_checked_call_agent(call_agent) + self.callbacks = callbacks + self.num_runs = num_runs + self.case_parallelism = case_parallelism + self._top_k = max(0, int(top_k_per_case)) + self._best_history: dict[str, list[dict[str, Any]]] = {} + from ._optimize_evaluator_call import EvaluationOutcome # local to avoid cycle + self.last_outcome: Optional[EvaluationOutcome] = None + # Long-lived event loop reused across every evaluate() call so + # async resources held inside call_agent (httpx.AsyncClient, + # asyncpg pools, grpc.aio channels, ...) stay bound to a single + # loop. Created lazily on first evaluate() because adapter is + # constructed from an async context; allocating the loop here + # would not bind to the worker thread that gepa.optimize runs in. + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def _get_or_create_loop(self) -> asyncio.AbstractEventLoop: + """Return the adapter-owned loop, creating it on first call. + + Must be invoked from the worker thread that drives gepa.optimize + (no running loop in that thread, so a fresh loop is safe). + """ + if self._loop is None or self._loop.is_closed(): + self._loop = asyncio.new_event_loop() + return self._loop + + def close(self) -> None: + """Close the adapter-owned loop. Idempotent; safe before first evaluate().""" + loop = getattr(self, "_loop", None) + self._loop = None + if loop is None or loop.is_closed(): + return + try: + loop.close() + except Exception: # pragma: no cover - defensive guard + pass + + def _record_history( + self, + *, + case_id: str, + score: float, + best_response: str, + ) -> None: + """Append one historical entry per case, keep at most top_k by score.""" + if self._top_k <= 0: + return + bucket = self._best_history.setdefault(case_id, []) + bucket.append({"score": float(score), "best_response": best_response}) + bucket.sort(key=lambda entry: entry["score"], reverse=True) + del bucket[self._top_k:] + + def evaluate( + self, + batch: list[EvalCase], + candidate: dict[str, str], + capture_traces: bool = False, + ) -> Any: + """Apply ``candidate`` and run the evaluator over ``batch``. + + Both the prompt write and the evaluator run execute on the + adapter-owned event loop so async resources held by call_agent + stay bound to a single loop across every gepa iteration. + """ + from gepa.core.adapter import EvaluationBatch + + loop = self._get_or_create_loop() + loop.run_until_complete(self.target_prompt.write_all(candidate)) + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + evalset_path = tmp_path / "batch.evalset.json" + metrics_path = tmp_path / "batch.metrics.json" + + # Unique id per call so the in-memory eval-set manager doesn't + # reject repeated batches. gepa's batch sampler pads minibatches + # with least-frequent ids when trainset_size doesn't divide + # minibatch_size, so the same eval_case can appear twice — rename + # duplicate eval_ids in place so the manager accepts the EvalSet + # and every minibatch position still gets scored. + seen: dict[str, int] = {} + unique_cases: list[EvalCase] = [] + for case in batch: + seen[case.eval_id] = seen.get(case.eval_id, 0) + 1 + if seen[case.eval_id] == 1: + unique_cases.append(case) + else: + cloned = case.model_copy() + cloned.eval_id = f"{case.eval_id}__rep{seen[case.eval_id]}" + unique_cases.append(cloned) + evalset = EvalSet( + eval_set_id=f"optimize_gepa_batch_{uuid.uuid4().hex[:8]}", + eval_cases=unique_cases, + ) + evalset_path.write_text(evalset.model_dump_json(indent=2), encoding="utf-8") + metrics_path.write_text(self.eval_config.model_dump_json(indent=2), encoding="utf-8") + + outcome = loop.run_until_complete( + run_evaluator( + eval_dataset_path=str(evalset_path), + eval_metrics_path=str(metrics_path), + call_agent=self.call_agent, + callbacks=self.callbacks, + num_runs=self.num_runs, + case_parallelism=self.case_parallelism, + )) + self.last_outcome = outcome + + return self._build_evaluation_batch( + batch=unique_cases, + result=outcome.raw_result, + capture_traces=capture_traces, + evaluation_batch_cls=EvaluationBatch, + ) + + def make_reflective_dataset( + self, + candidate: dict[str, str], + eval_batch: Any, + components_to_update: list[str], + ) -> Mapping[str, Sequence[Mapping[str, Any]]]: + """Render failed-case trajectories into GEPA's reflective dataset shape. + + Each record is a turn-sliced dict tuned for multi-component / + multi-turn / multi-run / tool-using / multi-metric scenarios: + + - ``case_id``: stable identifier for cross-referencing. + - ``score``: aggregated case score in ``[0, 1]``; ``1.0`` = + every metric passed. + - ``Case Body``: turn-sliced markdown — see :func:`_build_case_body`. + - ``Other Active Components`` *(optional)*: current text of + every other prompt in the candidate. Present only when the + candidate has more than one component and the others + contain text. See :func:`_build_other_active_components`. + + Cases on the evaluator-error path (no runs produced) surface a + minimal record whose Case Body is the captured ``error_message``, + so the reflection LM still sees that the case failed. + """ + if not components_to_update: + return {} + + trajectories = getattr(eval_batch, "trajectories", None) + if not trajectories: + return {comp: [] for comp in components_to_update} + + # Per-component records: ``Other Active Components`` depends on + # which component is being rewritten this round, so rebuild it. + dataset: dict[str, list[Mapping[str, Any]]] = {} + for comp in components_to_update: + other_components_md = _build_other_active_components(candidate, [comp]) + records: list[Mapping[str, Any]] = [] + for traj in trajectories: + score = traj.get("score", 0.0) + if score >= 1.0: + continue + + case = traj.get("_case") + case_runs = traj.get("_case_runs") or [] + if not isinstance(case, EvalCase): + continue + + case_body = (_build_case_body(case, case_runs) if case_runs else "") + if not case_body: + # Evaluator-error path: fall back to the captured + # error_message so the LM still gets a diagnostic. + case_body = (traj.get("error_message") or "(no trajectory data captured)") + record: dict[str, Any] = { + "case_id": case.eval_id, + "score": float(score), + "Case Body": case_body, + } + history = self._best_history.get(case.eval_id, [])[:self._top_k] + if history: + record["history_top_k"] = history + if other_components_md: + record["Other Active Components"] = other_components_md + records.append(record) + dataset[comp] = records + return dataset + + def _build_evaluation_batch( + self, + *, + batch: list[EvalCase], + result: Optional[EvaluateResult], + capture_traces: bool, + evaluation_batch_cls: type, + ) -> Any: + scores: list[float] = [] + outputs: list[Any] = [] + trajectories: Optional[list[dict[str, Any]]] = [] if capture_traces else None + # Per-case per-metric scores. Dropped to ``None`` after the loop + # if no metric data was collected, so gepa's per-objective + # frontier stays inactive when the evaluator emits none. + objective_scores: list[dict[str, float]] = [] + + if result is None or not result.results_by_eval_set_id: + for case in batch: + scores.append(0.0) + outputs.append("") + objective_scores.append({}) + if trajectories is not None: + trajectories.append(_build_trajectory_entry(case, 0.0, error_message="no result returned")) + return evaluation_batch_cls( + outputs=outputs, + scores=scores, + trajectories=trajectories, + objective_scores=None, + ) + + set_result = next(iter(result.results_by_eval_set_id.values())) + + for case in batch: + case_runs = set_result.eval_results_by_eval_id.get(case.eval_id, []) + if not case_runs: + scores.append(0.0) + outputs.append("") + objective_scores.append({}) + if trajectories is not None: + trajectories.append( + _build_trajectory_entry( + case, + 0.0, + error_message="case missing from evaluator result", + )) + continue + + case_score = _continuous_case_score(case_runs) + scores.append(case_score) + objective_scores.append(_per_metric_objective_scores(case_runs)) + + first_run = case_runs[0] + outputs.append(_extract_case_output(first_run)) + + self._record_history( + case_id=case.eval_id, + score=case_score, + best_response=_extract_case_output(first_run), + ) + + if trajectories is not None: + trajectories.append(_build_trajectory_entry(case, case_score, case_runs=case_runs)) + + # Keep the field active when ANY case produced a non-empty metric map; + # GEPA treats ``None`` as "no per-objective data". + has_objective_data = any(scores_map for scores_map in objective_scores) + return evaluation_batch_cls( + outputs=outputs, + scores=scores, + trajectories=trajectories, + objective_scores=objective_scores if has_objective_data else None, + ) diff --git a/trpc_agent_sdk/evaluation/_optimize_gepa_callback.py b/trpc_agent_sdk/evaluation/_optimize_gepa_callback.py new file mode 100644 index 000000000..a7588ef8d --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_gepa_callback.py @@ -0,0 +1,381 @@ +# 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. +"""GEPACallback adapter buffering real-time iteration events as RoundRecords. + +Implements ``gepa.core.callbacks.GEPACallback`` so the framework captures the +full reflective lifecycle for each iteration: + + * ``on_iteration_start`` — reset per-iteration buffer; snapshot the + reflection-LM counters so per-round deltas + are correct. + * ``on_minibatch_sampled`` — record train minibatch size for the round. + * ``on_proposal_end`` — capture which components the reflection LM + actually rewrote this round (gepa's + component selector, e.g. RoundRobin, may + mutate only a subset of the candidate's + components per round). + * ``on_evaluation_end`` — capture parent / candidate subsample scores + (the first two non-seed evaluations of an + iteration are parent + candidate on the + sampled minibatch). + * ``on_evaluation_skipped`` — capture the skip reason that prevented a + full validation evaluation (e.g. subsample + gate did not pass). + * ``on_valset_evaluated`` — capture the full validation pass rate, + metric breakdown and failed case ids; the + ``iteration == 0`` event is recorded as the + baseline instead of a round. + * ``on_merge_attempted`` — tag the current round as a ``"merge"`` round. + * ``on_budget_updated`` — track the gepa-reported ``metric_calls_used`` + counter so the reporter shows real budget + usage instead of a derived estimate. + * ``on_iteration_end`` — flush a complete RoundRecord (always, even + for rounds rejected at the subsample gate); + emit a RoundView for the attached reporter. +""" + +from __future__ import annotations + +from datetime import datetime +from datetime import timezone +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Mapping +from typing import Optional + +from ._optimize_result import RoundRecord + +if TYPE_CHECKING: + from ._optimize_reporter import OptimizeReporter + +# Translate gepa's skip reason literals into user-facing wording. +# Source: reference/gepa reflective_mutation.py:299, :320. +_GEPA_SKIP_REASON_MAP: dict[str, str] = { + "no_trajectories": "no trajectories captured this round", + "all_scores_perfect": "minibatch already perfect (skip_perfect_score on)", +} + +# Used when a round produced no candidate without emitting evaluation_skipped. +_NO_PROPOSAL_FALLBACK: str = "reflect-LM produced no usable new prompt" + + +def _translate_skip_reason(raw: Optional[str]) -> Optional[str]: + """Translate a gepa skip reason; unknown values surface under ``gepa-internal:``.""" + if raw is None: + return None + text = str(raw).strip() + if not text: + return None + if text in _GEPA_SKIP_REASON_MAP: + return _GEPA_SKIP_REASON_MAP[text] + normalised = text.lower().replace(" ", "_").replace("-", "_") + if normalised in _GEPA_SKIP_REASON_MAP: + return _GEPA_SKIP_REASON_MAP[normalised] + return f"gepa-internal: {text}" + + +class _AgentGEPACallback: + """Buffer per-iteration RoundRecords for GepaReflectiveOptimizer. + + Attributes: + rounds: list of RoundRecord populated during gepa.optimize() execution. + baseline_metric_breakdown: metric breakdown for the seed candidate + captured from the iteration-0 valset evaluation event. + baseline_failed_case_ids: failed case ids for the seed candidate. + baseline_pass_rate: average validation score for the seed candidate. + """ + + def __init__( + self, + *, + adapter: Any = None, + reflection_lm: Any = None, + reporter: Optional["OptimizeReporter"] = None, + train_size: int = 0, + budget_total: Optional[int] = None, + metric_thresholds: Optional[Mapping[str, float]] = None, + on_valset_breakdown: Optional[Callable[[dict[str, float]], None]] = None, + ) -> None: + self.rounds: list[RoundRecord] = [] + self.baseline_metric_breakdown: dict[str, float] = {} + self.baseline_failed_case_ids: list[str] = [] + self.baseline_pass_rate: float = 0.0 + self._adapter = adapter + self._reflection_lm = reflection_lm + self._reporter = reporter + self._train_size = int(train_size) + self._budget_total = budget_total + self._metric_thresholds = dict(metric_thresholds or {}) + self._on_valset_breakdown = on_valset_breakdown + self._budget_used: int = 0 + self._reset_iter_buffer() + self._calls_at_iter_start: int = 0 + self._cost_at_iter_start: float = 0.0 + self._tokens_at_iter_start: dict[str, int] = { + "prompt": 0, + "completion": 0, + "total": 0, + } + + def _reset_iter_buffer(self) -> None: + self._iter_started_at: Optional[datetime] = None + self._iter_iteration: int = 0 + self._iter_candidate: Optional[dict[str, str]] = None + self._iter_val_score: Optional[float] = None + self._iter_is_best: bool = False + self._iter_metric_breakdown: dict[str, float] = {} + self._iter_failed_case_ids: list[str] = [] + self._iter_train_minibatch_size: int = 0 + self._iter_train_size: int = self._train_size + self._iter_train_parent_score: Optional[float] = None + self._iter_train_candidate_score: Optional[float] = None + self._iter_skip_reason: Optional[str] = None + self._iter_error_message: Optional[str] = None + self._iter_kind: str = "reflective" + # Components rewritten this round (set by on_proposal_end). None + # means no proposal event observed for the iteration. + self._iter_changed_components: Optional[list[str]] = None + + def on_iteration_start(self, event: Mapping[str, Any]) -> None: + self._reset_iter_buffer() + self._iter_started_at = datetime.now(timezone.utc) + self._iter_iteration = int(event.get("iteration", 0)) + if self._reflection_lm is not None: + self._calls_at_iter_start = int(getattr(self._reflection_lm, "total_calls", 0)) + self._cost_at_iter_start = float(getattr(self._reflection_lm, "total_cost", 0.0)) + usage = getattr(self._reflection_lm, "total_token_usage", None) or {} + self._tokens_at_iter_start = { + "prompt": int(usage.get("prompt", 0)), + "completion": int(usage.get("completion", 0)), + "total": int(usage.get("total", 0)), + } + + def on_minibatch_sampled(self, event: Mapping[str, Any]) -> None: + minibatch_ids = event.get("minibatch_ids") or [] + self._iter_train_minibatch_size = len(minibatch_ids) + trainset_size = event.get("trainset_size") + if isinstance(trainset_size, int) and trainset_size > 0: + self._iter_train_size = trainset_size + + def on_proposal_end(self, event: Mapping[str, Any]) -> None: + """Capture which components the reflection LM rewrote this round. + + gepa's component selector (e.g. ``RoundRobinReflectionComponentSelector``) + chooses a subset of the candidate's components per round; only + components that produced a non-empty new instruction land in + ``new_instructions``, making it the authoritative source for the + ``optimized_field_names`` field on the buffered RoundRecord. Code + paths that bypass this event (e.g. merge rounds) leave the + marker ``None`` so ``on_iteration_end`` falls back to + ``candidate.keys()``. + """ + new_instructions = event.get("new_instructions") + if isinstance(new_instructions, Mapping): + self._iter_changed_components = list(new_instructions.keys()) + + def on_evaluation_end(self, event: Mapping[str, Any]) -> None: + """Record subsample scores for the parent and the new candidate. + + gepa marks the post-mutation / post-merge evaluation with + ``candidate_idx=None`` (reflective_mutation.py:430 emits None for + the new-candidate eval; merge.py:376 also uses None for the + post-merge eval). Every other evaluation_end carries an int + ``candidate_idx`` and represents the parent / current-program + eval. Routing on this field is more reliable than counting + event order — earlier seq-based logic misclassified rounds + where the reflective proposer picked the seed program (id=0) + as parent, because gepa flags that parent eval with + ``is_seed_candidate=True`` and the previous early-return + dropped the parent score, shifting the candidate score into + the parent slot. + """ + scores = event.get("scores") or [] + if not scores: + return + avg = sum(float(s) for s in scores) / max(1, len(scores)) + if event.get("candidate_idx") is None: + # New candidate evaluation (reflective post-mutation OR + # merge post-merge). + self._iter_train_candidate_score = avg + else: + # Parent / current-program evaluation. + self._iter_train_parent_score = avg + if not self._iter_train_minibatch_size: + self._iter_train_minibatch_size = len(scores) + + def on_evaluation_skipped(self, event: Mapping[str, Any]) -> None: + translated = _translate_skip_reason(event.get("reason")) + if translated: + self._iter_skip_reason = translated + + def on_merge_attempted(self, event: Mapping[str, Any]) -> None: + self._iter_kind = "merge" + + def on_budget_updated(self, event: Mapping[str, Any]) -> None: + used = event.get("metric_calls_used") + if isinstance(used, int): + self._budget_used = used + + def on_error(self, event: Mapping[str, Any]) -> None: + exc = event.get("exception") + if exc is not None: + self._iter_error_message = str(exc) + + def on_valset_evaluated(self, event: Mapping[str, Any]) -> None: + candidate = event.get("candidate") + if candidate is None: + return + # adapter.last_outcome was set immediately before gepa emits this + # event, so the breakdown / failures correspond to ``candidate``. + outcome = getattr(self._adapter, "last_outcome", None) if self._adapter else None + metric_breakdown: dict[str, float] = {} + failed_case_ids: list[str] = [] + if outcome is not None: + metric_breakdown = dict(getattr(outcome, "metric_breakdown", {})) + failed_case_ids = list(getattr(outcome, "failed_case_ids", [])) + + if self._on_valset_breakdown is not None: + try: + self._on_valset_breakdown(dict(metric_breakdown)) + except Exception: # pragma: no cover - never break loop on stopper error + pass + + if int(event.get("iteration", -1)) == 0: + self.baseline_metric_breakdown = metric_breakdown + self.baseline_failed_case_ids = failed_case_ids + self.baseline_pass_rate = float(event.get("average_score", 0.0)) + if self._reporter is not None: + try: + self._reporter.baseline_evaluated( + self.baseline_pass_rate, + dict(self.baseline_metric_breakdown), + metric_thresholds=dict(self._metric_thresholds), + ) + except Exception: # pragma: no cover - never break loop on reporter error + pass + return + + self._iter_candidate = dict(candidate) + self._iter_val_score = float(event.get("average_score", 0.0)) + self._iter_is_best = bool(event.get("is_best_program", False)) + self._iter_metric_breakdown = metric_breakdown + self._iter_failed_case_ids = failed_case_ids + + def on_iteration_end(self, event: Mapping[str, Any]) -> None: + """Flush a RoundRecord for the iteration regardless of acceptance. + + Iterations rejected at the subsample gate (``_iter_candidate`` stays + None) are still recorded so the reporter timeline matches gepa's + actual progression and round indices stay contiguous. + """ + iteration = int(event.get("iteration", self._iter_iteration)) + started_at = self._iter_started_at or datetime.now(timezone.utc) + finished_at = datetime.now(timezone.utc) + duration = max(0.0, (finished_at - started_at).total_seconds()) + proposal_accepted = bool(event.get("proposal_accepted", False)) + candidate_seen = self._iter_candidate is not None + accepted = proposal_accepted and candidate_seen + + if self._iter_error_message: + reason = f"error: {self._iter_error_message}" + elif self._iter_skip_reason: + reason = f"skipped: {self._iter_skip_reason}" + elif candidate_seen: + score = self._iter_val_score or 0.0 + reason = (f"GEPA accepted proposal (val_score={score:.4f})" + if accepted else f"Explored by GEPA (val_score={score:.4f})") + else: + reason = "no candidate produced this round" + + reflection_calls_delta = 0 + round_llm_cost = 0.0 + round_token_usage = {"prompt": 0, "completion": 0, "total": 0} + if self._reflection_lm is not None: + reflection_calls_delta = max( + 0, + int(getattr(self._reflection_lm, "total_calls", 0)) - self._calls_at_iter_start, + ) + round_llm_cost = max( + 0.0, + float(getattr(self._reflection_lm, "total_cost", 0.0)) - self._cost_at_iter_start, + ) + cur = getattr(self._reflection_lm, "total_token_usage", None) or {} + for key in ("prompt", "completion", "total"): + round_token_usage[key] = max( + 0, + int(cur.get(key, 0)) - self._tokens_at_iter_start.get(key, 0), + ) + + validation_pass_rate = (self._iter_val_score if self._iter_val_score is not None else 0.0) + candidate_prompts = (dict(self._iter_candidate) if candidate_seen else {}) + # Authoritative source: components captured from on_proposal_end. + # Fallback to full candidate keys for rounds without a proposal + # event (e.g. merge rounds — "rewrite" doesn't apply, listing all + # keys is the least misleading default). + if self._iter_changed_components is not None: + optimized_field_names = list(self._iter_changed_components) + elif candidate_seen: + optimized_field_names = list(self._iter_candidate.keys()) + else: + optimized_field_names = [] + + skip_reason = self._iter_skip_reason + if (not candidate_seen and skip_reason is None and self._iter_error_message is None): + skip_reason = _NO_PROPOSAL_FALLBACK + + record = RoundRecord( + round=iteration, + optimized_field_names=optimized_field_names, + candidate_prompts=candidate_prompts, + train_pass_rate=0.0, + validation_pass_rate=validation_pass_rate, + metric_breakdown=dict(self._iter_metric_breakdown), + accepted=accepted, + acceptance_reason=reason, + failed_case_ids=list(self._iter_failed_case_ids), + reflection_lm_calls=reflection_calls_delta, + round_llm_cost=round_llm_cost, + round_token_usage=round_token_usage, + started_at=started_at.isoformat(), + duration_seconds=duration, + kind=self._iter_kind if self._iter_kind in ("reflective", "merge") else "reflective", + train_minibatch_size=self._iter_train_minibatch_size, + train_subsample_parent_score=self._iter_train_parent_score, + train_subsample_candidate_score=self._iter_train_candidate_score, + skip_reason=skip_reason, + error_message=self._iter_error_message, + budget_used=self._budget_used if self._budget_used else None, + budget_total=self._budget_total, + ) + self.rounds.append(record) + + if self._reporter is not None: + try: + self._emit_round_completed(record) + except Exception: # pragma: no cover - never break loop on reporter error + pass + + def _emit_round_completed(self, record: RoundRecord) -> None: + """Translate a freshly buffered RoundRecord into a RoundView event.""" + from ._optimize_reporter import RoundView + + view = RoundView( + round=record.round, + kind=record.kind, + train_minibatch_size=record.train_minibatch_size, + train_size=self._iter_train_size or self._train_size, + train_subsample_parent_score=record.train_subsample_parent_score, + train_subsample_candidate_score=record.train_subsample_candidate_score, + val_pass_rate=(record.validation_pass_rate if record.candidate_prompts else None), + accepted=record.accepted, + skip_reason=record.skip_reason, + error_message=record.error_message, + duration_seconds=record.duration_seconds, + budget_used=record.budget_used, + budget_total=record.budget_total, + ) + self._reporter.round_completed(view) diff --git a/trpc_agent_sdk/evaluation/_optimize_gepa_reflective.py b/trpc_agent_sdk/evaluation/_optimize_gepa_reflective.py new file mode 100644 index 000000000..322340eb9 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_gepa_reflective.py @@ -0,0 +1,612 @@ +# 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. +"""GEPA reflective optimizer: BaseOptimizer subclass driving ``gepa.optimize()``. + +Hosts the ``gepa_reflective`` algorithm and its registry entry. The GEPA +protocol adapter and trajectory helpers live in +:mod:`_optimize_gepa_adapter`; the reflection-LM wrapper lives in +:mod:`_optimize_model_callable`. + +``gepa`` is an optional dependency: ``gepa.optimize`` and the stopper +classes are imported lazily inside :meth:`GepaReflectiveOptimizer._call_gepa_optimize` +and :meth:`GepaReflectiveOptimizer._build_stop_callbacks`, so importing +this module without ``gepa`` installed succeeds but ``run()`` then fails +fast with an informative ImportError. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from datetime import timezone +from pathlib import Path +from typing import Any +from typing import Optional + +from ._base_optimizer import BaseOptimizer +from ._eval_case import EvalCase +from ._eval_config import EvalConfig +from ._eval_set import EvalSet +from ._optimize_config import FrameworkStopConfig +from ._optimize_config import GepaReflectiveAlgo +from ._optimize_gepa_adapter import _AgentGEPAAdapter +from ._optimize_gepa_callback import _AgentGEPACallback +from ._optimize_metric_info import build_metric_reference_doc +from ._optimize_metric_info import build_reflection_prompt_template +from ._optimize_model_callable import _OptimizeModelCallable +from ._optimize_reporter import OptimizeReporter +from ._optimize_reporter import _SilentGepaLogger +from ._optimize_result import OptimizeResult +from ._optimize_result import RoundRecord +from ._optimize_result import StopReason + + +def _load_evalset_cases(path: str) -> list[EvalCase]: + """Read an EvalSet JSON file and return its eval_cases list. + + Raises: + FileNotFoundError: if path does not exist. + pydantic.ValidationError: on schema violations. + """ + content = Path(path).read_text(encoding="utf-8") + evalset = EvalSet.model_validate_json(content) + return list(evalset.eval_cases) + + +def _collect_metric_thresholds(eval_config: EvalConfig) -> dict[str, float]: + """Return ``{metric_name: threshold}`` for every metric in the evaluator config. + + Mirrors what the local evaluator and per-metric evaluators consume so the + reporter and the persisted result share one source of truth for thresholds. + """ + return {metric.metric_name: float(metric.threshold) for metric in eval_config.get_eval_metrics()} + + +class _LabeledStopper: + """Wrap a gepa StopperProtocol with a stable :data:`StopReason` label. + + Delegates ``__call__`` to the inner stopper and exposes a sticky + ``last_triggered`` flag set the first time the inner stopper returns + ``True``. ``_classify_stop_reason`` reads the label after gepa + returns to map back to a single ``stop_reason`` enum value. + """ + + def __init__(self, inner: Any, label: StopReason) -> None: + self._inner = inner + self.label: StopReason = label + self.last_triggered: bool = False + + def __call__(self, *args: Any, **kwargs: Any) -> bool: + result = bool(self._inner(*args, **kwargs)) + if result: + self.last_triggered = True + return result + + +class _RequiredMetricsAboveThresholdStopper: + """gepa Stopper that fires once every required metric meets its threshold. + + Backs the framework-level ``stop.required_metrics`` policy. Each + iteration's per-metric breakdown is pushed via ``update`` (called by + ``_AgentGEPACallback.on_valset_breakdown``); ``__call__`` returns + True as soon as that breakdown clears every threshold, halting the + run with ``stop_reason="required_metrics_passing"``. + + Attributes: + last_triggered: Sticky flag set the first time ``__call__`` + returned True. + """ + + def __init__(self, required_thresholds: dict[str, float]) -> None: + self._thresholds: dict[str, float] = dict(required_thresholds) + self._latest: dict[str, float] = {} + self.last_triggered: bool = False + + def update(self, breakdown: dict[str, float]) -> None: + """Record the most recent per-metric breakdown observed on the valset.""" + self._latest = dict(breakdown) + + def __call__(self, gepa_state: Any = None) -> bool: + triggered = BaseOptimizer.metrics_meet_thresholds(self._latest, self._thresholds) + if triggered: + self.last_triggered = True + return triggered + + +def _build_optimize_result( + *, + gepa_result: Any, + baseline_prompts: dict[str, str], + best_candidate: dict[str, str], + reflection_lm_cost: float, + started_at: datetime, + finished_at: datetime, + algo_name: str, + finish_reason: str = "completed", + callback_rounds: Optional[list[RoundRecord]] = None, + baseline_metric_breakdown: Optional[dict[str, float]] = None, + metric_thresholds: Optional[dict[str, float]] = None, + stop_reason: Optional[StopReason] = None, + total_reflection_lm_calls: int = 0, + total_judge_model_calls: int = 0, + total_judge_cost: float = 0.0, + total_token_usage: Optional[dict[str, int]] = None, +) -> OptimizeResult: + """Map a successful GEPAResult into the framework's OptimizeResult schema. + + Round source priority: + 1. ``callback_rounds`` — real-time RoundRecord buffer from + :class:`_AgentGEPACallback` (used in production whenever gepa + emits iteration events). + 2. Post-hoc reconstruction from ``gepa_result.candidates`` / + ``val_aggregate_scores`` — fallback for callers that don't + install the callback (e.g. mock-driven unit tests, older gepa + versions). + + Args: + baseline_metric_breakdown: Per-metric mean for the baseline + candidate, captured by callback at iteration 0. + total_reflection_lm_calls: Reflection LM invocation count. + total_judge_model_calls: Evaluator-internal judge LM count. + total_judge_cost: USD cost charged to the judge LM (added to + reflection-LM cost). + total_token_usage: ``{"prompt", "completion", "total"}`` for the + reflection LM, optionally merged with judge token usage. + """ + val_scores = list(gepa_result.val_aggregate_scores) + baseline_pass_rate = float(val_scores[0]) if val_scores else 0.0 + best_idx = int(gepa_result.best_idx) + best_pass_rate = float(val_scores[best_idx]) if val_scores else 0.0 + + started_iso = started_at.isoformat() + if callback_rounds: + rounds = list(callback_rounds) + else: + # Fallback path: no callback event stream available. gepa_result + # alone doesn't carry per-round mutation metadata, so fields + # below use the most-conservative approximation: + # * optimized_field_names: all candidate keys (no signal for + # which subset the reflection LM actually rewrote — the + # callback path narrows this via on_proposal_end). + # * accepted: equated with is_best, since GEPAResult only + # reports the final winner, not per-round acceptance. + candidates = list(gepa_result.candidates) + rounds = [] + for i in range(1, len(candidates)): + candidate = dict(candidates[i]) + score = float(val_scores[i]) if i < len(val_scores) else 0.0 + is_best = i == best_idx + rounds.append( + RoundRecord( + round=i, + optimized_field_names=list(candidate.keys()), + candidate_prompts=candidate, + train_pass_rate=0.0, + validation_pass_rate=score, + accepted=is_best, + acceptance_reason=(f"Selected as best by GEPA (val_score={score:.4f})" + if is_best else f"Explored by GEPA (val_score={score:.4f})"), + started_at=started_iso, + duration_seconds=0.0, + )) + + best_metric_breakdown: dict[str, float] = {} + for record in rounds: + if record.candidate_prompts == best_candidate and record.metric_breakdown: + best_metric_breakdown = dict(record.metric_breakdown) + break + + # When gepa finds no improvement (best_idx == 0), best_candidate equals + # the seed prompts and the loop above never matches — iteration 0 is + # captured as ``baseline_metric_breakdown`` rather than a RoundRecord. + # Mirror baseline data into ``best`` so summary.txt shows + # ``baseline -> baseline`` (no improvement) instead of + # ``baseline -> nan`` (looks like data loss). + if (not best_metric_breakdown and best_candidate == baseline_prompts and baseline_metric_breakdown): + best_metric_breakdown = dict(baseline_metric_breakdown) + + extras: dict[str, Any] = {} + total_metric_calls = getattr(gepa_result, "total_metric_calls", None) + if total_metric_calls is not None: + extras["total_metric_calls"] = int(total_metric_calls) + + duration_seconds = max(0.0, (finished_at - started_at).total_seconds()) + token_usage = dict(total_token_usage) if total_token_usage else { + "prompt": 0, + "completion": 0, + "total": 0, + } + + # GEPA's per_objective_best_candidates is dict[str, set[int]] | None; + # convert to dict[str, list[int]] (sorted) for stable JSON output. + raw_per_metric_best = getattr(gepa_result, "per_objective_best_candidates", None) + per_metric_best: dict[str, list[int]] = {} + if isinstance(raw_per_metric_best, dict): + for metric_name, indices in raw_per_metric_best.items(): + try: + per_metric_best[str(metric_name)] = sorted(int(i) for i in indices) + except (TypeError, ValueError): + continue + + return OptimizeResult( + algorithm=algo_name, + status="SUCCEEDED", + finish_reason=finish_reason, + stop_reason=stop_reason, + baseline_pass_rate=baseline_pass_rate, + best_pass_rate=best_pass_rate, + pass_rate_improvement=best_pass_rate - baseline_pass_rate, + baseline_metric_breakdown=dict(baseline_metric_breakdown or {}), + best_metric_breakdown=best_metric_breakdown, + metric_thresholds=dict(metric_thresholds or {}), + per_metric_best_candidates=per_metric_best, + baseline_prompts=dict(baseline_prompts), + best_prompts=dict(best_candidate), + total_rounds=len(rounds), + rounds=rounds, + total_reflection_lm_calls=int(total_reflection_lm_calls), + total_judge_model_calls=int(total_judge_model_calls), + total_llm_cost=float(reflection_lm_cost) + float(total_judge_cost), + total_token_usage=token_usage, + duration_seconds=duration_seconds, + started_at=started_iso, + finished_at=finished_at.isoformat(), + extras=extras, + ) + + +def _build_failed_result( + *, + baseline_prompts: dict[str, str], + started_at: datetime, + finished_at: datetime, + error_message: str, + algo_name: str, + metric_thresholds: Optional[dict[str, float]] = None, +) -> OptimizeResult: + """Build a FAILED OptimizeResult preserving the baseline as the best prompts.""" + return OptimizeResult( + algorithm=algo_name, + status="FAILED", + finish_reason="error", + error_message=error_message, + baseline_pass_rate=0.0, + best_pass_rate=0.0, + pass_rate_improvement=0.0, + metric_thresholds=dict(metric_thresholds or {}), + baseline_prompts=dict(baseline_prompts), + best_prompts=dict(baseline_prompts), + total_rounds=0, + rounds=[], + total_reflection_lm_calls=0, + total_judge_model_calls=0, + total_llm_cost=0.0, + duration_seconds=max(0.0, (finished_at - started_at).total_seconds()), + started_at=started_at.isoformat(), + finished_at=finished_at.isoformat(), + extras={}, + ) + + +def _build_stop_callbacks( + algo: GepaReflectiveAlgo, + stop_config: FrameworkStopConfig, + metric_thresholds: dict[str, float], + *, + output_dir: Optional[str] = None, +) -> tuple[list[Any], Optional[_RequiredMetricsAboveThresholdStopper]]: + """Translate stop fields into gepa StopperProtocol instances. + + Each non-None ``algo`` field maps to one gepa-native stopper + (max_metric_calls, no_improvement, timeout, score_threshold, + max_candidate_proposals, max_tracked_candidates). + + The framework-level :class:`FrameworkStopConfig` adds the + metric-thresholds policy via + :class:`_RequiredMetricsAboveThresholdStopper` when + ``stop_config.required_metrics`` resolves to a non-empty subset of + ``metric_thresholds``. That instance is also returned so the caller + can inspect ``last_triggered`` for stop-reason classification. + + When ``output_dir`` is supplied, a :class:`gepa.utils.FileStopper` + watches ``/optimize.stop``: creating that file (e.g. + ``touch $OUTPUT_DIR/optimize.stop``) halts gepa cleanly at the next + poll and surfaces as ``stop_reason="user_requested_stop"``. + + Returns: + ``(stop_callbacks, framework_stopper)`` — ``framework_stopper`` + is ``None`` when no per-metric thresholds are enforced. + """ + from gepa.utils.stop_condition import MaxCandidateProposalsStopper + from gepa.utils.stop_condition import MaxMetricCallsStopper + from gepa.utils.stop_condition import MaxTrackedCandidatesStopper + from gepa.utils.stop_condition import NoImprovementStopper + from gepa.utils.stop_condition import ScoreThresholdStopper + from gepa.utils.stop_condition import TimeoutStopCondition + + callbacks: list[Any] = [] + if algo.max_metric_calls is not None: + callbacks.append(_LabeledStopper( + MaxMetricCallsStopper(int(algo.max_metric_calls)), + "budget_exhausted", + )) + if algo.max_iterations_without_improvement is not None: + callbacks.append( + _LabeledStopper( + NoImprovementStopper(int(algo.max_iterations_without_improvement)), + "no_improvement", + )) + if algo.timeout_seconds is not None: + callbacks.append(_LabeledStopper( + TimeoutStopCondition(float(algo.timeout_seconds)), + "timeout", + )) + if algo.score_threshold is not None: + callbacks.append(_LabeledStopper( + ScoreThresholdStopper(float(algo.score_threshold)), + "score_threshold", + )) + if algo.max_candidate_proposals is not None: + callbacks.append( + _LabeledStopper( + MaxCandidateProposalsStopper(int(algo.max_candidate_proposals)), + "max_candidate_proposals", + )) + if algo.max_tracked_candidates is not None: + callbacks.append( + _LabeledStopper( + MaxTrackedCandidatesStopper(int(algo.max_tracked_candidates)), + "max_tracked_candidates", + )) + + framework_stopper: Optional[_RequiredMetricsAboveThresholdStopper] = None + required = BaseOptimizer.resolve_required_thresholds(stop_config, metric_thresholds) + if required: + framework_stopper = _RequiredMetricsAboveThresholdStopper(required) + callbacks.append(framework_stopper) + + if output_dir is not None: + import os as _os + from gepa.utils import FileStopper + + callbacks.append( + _LabeledStopper( + FileStopper(_os.path.join(output_dir, "optimize.stop")), + "user_requested_stop", + )) + + return callbacks, framework_stopper + + +def _classify_stop_reason( + *, + stop_callbacks: list[Any], + framework_stopper: Optional[_RequiredMetricsAboveThresholdStopper], +) -> StopReason: + """Pick the most-specific :data:`StopReason` for an ended gepa run. + + Resolution order: + 1. Framework-level ``required_metrics`` policy (highest priority + because users explicitly opt in). + 2. First :class:`_LabeledStopper` whose ``last_triggered`` is True + (insertion order breaks ties when gepa polled multiple stoppers + in the same tick). + 3. ``"completed"`` when no stopper fired (gepa loop ended + naturally, e.g. exhausted candidate proposals). + """ + if framework_stopper is not None and framework_stopper.last_triggered: + return "required_metrics_passing" + for stopper in stop_callbacks: + if isinstance(stopper, _LabeledStopper) and stopper.last_triggered: + return stopper.label + return "completed" + + +class GepaReflectiveOptimizer(BaseOptimizer): + """BaseOptimizer driving ``gepa.optimize()`` with the framework adapter. + + Flow inside :meth:`run`: + 1. Snapshot baseline prompts via ``TargetPrompt.read_all``. + 2. Load training / validation eval cases. + 3. Build :class:`_AgentGEPAAdapter` and + :class:`_OptimizeModelCallable` (gepa-compatible reflection LM). + 4. Run ``gepa.optimize`` in a worker thread (``asyncio.to_thread``) + so its sync main loop does not block the surrounding event loop. + 5. On success, return a populated :class:`OptimizeResult`; on + failure, return a FAILED result preserving the baseline prompts. + + The facade (``AgentOptimizer.optimize``) decides whether to persist + the winning candidate based on the ``update_source`` flag. + """ + + async def _call_gepa_optimize(self, **kwargs: Any) -> Any: + """Run gepa.optimize in a thread; isolated for tests to monkeypatch.""" + from gepa import optimize as gepa_optimize # lazy import; gepa is optional + + return await asyncio.to_thread(gepa_optimize, **kwargs) + + async def run( + self, + *, + reporter: Optional[OptimizeReporter] = None, + ) -> OptimizeResult: + algo: GepaReflectiveAlgo = self.config.optimize.algorithm + algo_name = algo.name + metric_thresholds = _collect_metric_thresholds(self.config.evaluate) + + started_at = datetime.now(timezone.utc) + baseline_prompts = await self.target_prompt.read_all() + seed_candidate = dict(baseline_prompts) + + try: + trainset = _load_evalset_cases(self.train_dataset_path) + valset = _load_evalset_cases(self.validation_dataset_path) + except Exception as ex: + return _build_failed_result( + baseline_prompts=baseline_prompts, + started_at=started_at, + finished_at=datetime.now(timezone.utc), + error_message=f"dataset load failed: {ex}", + algo_name=algo_name, + metric_thresholds=metric_thresholds, + ) + + adapter = _AgentGEPAAdapter( + target_prompt=self.target_prompt, + eval_config=self.config.evaluate, + call_agent=self.call_agent, + callbacks=self.callbacks, + num_runs=self.config.evaluate.num_runs, + case_parallelism=self.config.optimize.eval_case_parallelism, + top_k_per_case=int(algo.reflection_history_top_k), + ) + reflection_lm = _OptimizeModelCallable(algo.reflection_lm) + + try: + return await self._run_with_adapter( + adapter=adapter, + reflection_lm=reflection_lm, + algo=algo, + algo_name=algo_name, + baseline_prompts=baseline_prompts, + seed_candidate=seed_candidate, + trainset=trainset, + valset=valset, + metric_thresholds=metric_thresholds, + started_at=started_at, + reporter=reporter, + ) + finally: + adapter.close() + + async def _run_with_adapter( + self, + *, + adapter: _AgentGEPAAdapter, + reflection_lm: _OptimizeModelCallable, + algo: GepaReflectiveAlgo, + algo_name: str, + baseline_prompts: dict[str, str], + seed_candidate: dict[str, str], + trainset: list, + valset: list, + metric_thresholds: dict[str, float], + started_at: datetime, + reporter: Optional[OptimizeReporter], + ) -> OptimizeResult: + try: + stop_callbacks, framework_stopper = _build_stop_callbacks( + algo, + self.config.optimize.stop, + metric_thresholds, + output_dir=self.output_dir, + ) + except ImportError as ex: + return _build_failed_result( + baseline_prompts=baseline_prompts, + started_at=started_at, + finished_at=datetime.now(timezone.utc), + error_message=f"gepa stop_callbacks unavailable: {ex}", + algo_name=algo_name, + metric_thresholds=metric_thresholds, + ) + + gepa_callback = _AgentGEPACallback( + adapter=adapter, + reflection_lm=reflection_lm, + reporter=reporter, + train_size=len(trainset), + budget_total=algo.max_metric_calls, + metric_thresholds=metric_thresholds, + on_valset_breakdown=(framework_stopper.update if framework_stopper is not None else None), + ) + + # Embed a metric reference doc in the reflection prompt template so + # the reflection LM understands each feedback row. Empty doc still + # yields a GEPA-valid template. + reflection_prompt_template = build_reflection_prompt_template(build_metric_reference_doc(self.config.evaluate)) + + gepa_kwargs: dict[str, Any] = dict( + seed_candidate=seed_candidate, + trainset=trainset, + valset=valset, + adapter=adapter, + reflection_lm=reflection_lm, + reflection_prompt_template=reflection_prompt_template, + callbacks=[gepa_callback, *self.extra_gepa_callbacks], + candidate_selection_strategy=algo.candidate_selection_strategy, + module_selector=algo.module_selector, + reflection_minibatch_size=algo.reflection_minibatch_size, + skip_perfect_score=algo.skip_perfect_score, + perfect_score=algo.perfect_score, + use_merge=algo.use_merge, + max_merge_invocations=algo.max_merge_invocations, + merge_val_overlap_floor=algo.merge_val_overlap_floor, + frontier_type=algo.frontier_type, + cache_evaluation=algo.cache_evaluation, + track_best_outputs=algo.track_best_outputs, + raise_on_exception=True, + seed=algo.seed, + display_progress_bar=False, + stop_callbacks=[*stop_callbacks, *self.extra_stop_callbacks], + ) + # ``max_metric_calls`` is also a direct kwarg for backwards + # compatibility with gepa builds lacking ``MaxMetricCallsStopper``. + if algo.max_metric_calls is not None: + gepa_kwargs["max_metric_calls"] = int(algo.max_metric_calls) + + # Silence gepa's stdout logger when a reporter is attached so its + # internal messages don't collide with the reporter timeline. + if reporter is not None: + gepa_kwargs["logger"] = _SilentGepaLogger(verbose=1) + try: + gepa_result = await self._call_gepa_optimize(**gepa_kwargs) + except Exception as ex: + return _build_failed_result( + baseline_prompts=baseline_prompts, + started_at=started_at, + finished_at=datetime.now(timezone.utc), + error_message=str(ex), + algo_name=algo_name, + metric_thresholds=metric_thresholds, + ) + + best_idx = int(gepa_result.best_idx) + best_candidate = dict(gepa_result.candidates[best_idx]) + + val_scores = list(gepa_result.val_aggregate_scores) + baseline_pass_rate = float(val_scores[0]) if val_scores else 0.0 + best_pass_rate = float(val_scores[best_idx]) if val_scores else 0.0 + if best_pass_rate >= 1.0 and baseline_pass_rate >= 1.0: + finish_reason = "perfect_pass_rate" + elif best_pass_rate <= baseline_pass_rate: + finish_reason = "no_improvement" + else: + finish_reason = "completed" + + stop_reason: StopReason = _classify_stop_reason( + stop_callbacks=stop_callbacks, + framework_stopper=framework_stopper, + ) + + return _build_optimize_result( + gepa_result=gepa_result, + baseline_prompts=baseline_prompts, + best_candidate=best_candidate, + reflection_lm_cost=reflection_lm.total_cost, + callback_rounds=gepa_callback.rounds, + started_at=started_at, + finished_at=datetime.now(timezone.utc), + algo_name=algo_name, + finish_reason=finish_reason, + baseline_metric_breakdown=dict(gepa_callback.baseline_metric_breakdown), + metric_thresholds=metric_thresholds, + stop_reason=stop_reason, + total_reflection_lm_calls=int(reflection_lm.total_calls), + total_judge_model_calls=0, + total_judge_cost=0.0, + total_token_usage=dict(reflection_lm.total_token_usage), + ) diff --git a/trpc_agent_sdk/evaluation/_optimize_metric_info.py b/trpc_agent_sdk/evaluation/_optimize_metric_info.py new file mode 100644 index 000000000..70c6f2b17 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_metric_info.py @@ -0,0 +1,534 @@ +# 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. +"""Metric reference doc builder for the optimize module. + +Renders a structured markdown "syllabus" describing how each +user-configured metric is computed, for injection into the reflection +LM's prompt template alongside the per-case feedback. + +The corpus is owned here (not delegated to each evaluator's +``get_metric_info()``) so wording can be tuned for what a rewriting LM +needs. + +Coverage: +- Excludes tool/algorithm-fixed metrics (``tool_trajectory_avg_score``, + ``response_match_score``, ``response_evaluation_score``). +- FinalResponseCriterion: text match modes / case sensitivity / ignore / + JSON tree / numeric tolerance / AND combination / custom compare. +- LLMJudgeCriterion: single/multi judge / six built-in aggregators / + parallel / rubrics / knowledge_tool_names / generation_config / think + mode / weights. +""" + +from __future__ import annotations + +import math +from typing import Any +from typing import Optional + +from ._eval_config import EvalConfig +from ._eval_metrics import EvalMetric +from ._eval_metrics import PrebuiltMetrics +from ._evaluator_registry import EVALUATOR_REGISTRY + +_SKIPPED_METRICS: frozenset[str] = frozenset({ + PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value, + PrebuiltMetrics.RESPONSE_MATCH_SCORE.value, + PrebuiltMetrics.RESPONSE_EVALUATION_SCORE.value, +}) + +_METRIC_DESCRIPTIONS: dict[str, str] = { + PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: ("Deterministic match between the agent's final response and the " + "reference answer. Each invocation scores 1.0 (match) or 0.0 (no " + "match); the case score is the mean across invocations."), + PrebuiltMetrics.LLM_FINAL_RESPONSE.value: ("An LLM judge inspects the agent's final response and returns a " + "holistic valid/invalid verdict (1.0 or 0.0) together with a " + "natural-language reason."), + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value: ("An LLM judge scores the agent's final response against a list of " + "rubric items. Each rubric is judged independently (0 or 1); the " + "overall score is the mean of sub-scores. The judge returns a per-" + "rubric reason explaining its verdict."), + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value: + ("An LLM judge inspects the knowledge content the agent retrieved via " + "tool calls and scores it against a list of rubric items. Each " + "rubric is judged independently (0 or 1); the overall score is the " + "mean of sub-scores."), +} + +_AGGREGATOR_EXPLANATIONS: dict[str, str] = { + "all_pass": "all judges must PASS for the metric to PASS (strictest).", + "any_pass": "any single judge passing is enough for the metric to PASS (most lenient).", + "majority_pass": "more than half of the judges must PASS.", + "avg": "arithmetic mean of judges' scores (uniform weighting).", + "weighted_avg": "weighted mean of judges' scores using each model's ``weight``.", + "weighted_majority": "weighted majority vote: passes when the weighted PASS vote exceeds the FAIL vote.", +} + +_HEADER = ("## Metrics Reference\n\n" + "The assistant's outputs are graded by the metrics below. UNDERSTAND THESE " + "BEFORE PROPOSING CHANGES — they determine whether your new instruction " + "improves or regresses the candidate.") + +_FOOTER_GUIDELINES = ("## Rewriting Guidelines\n\n" + "1. **Preserve passing metrics.** A metric currently above its threshold " + "must not be sacrificed to fix a failing one.\n" + "2. **Use per-rubric sub-scores.** When a metric's per-case feedback " + "includes ``rubric_scores``, the failing sub-rubric tells you exactly " + "what's missing — and the passing ones tell you what to keep.\n" + "3. **Criterion-based metrics are deterministic.** The agent's output " + "must literally satisfy the matching rule (a ``contains`` rule means " + "the actual output has to include the expected substring verbatim).\n" + "4. **LLM-judged metrics evaluate qualities.** The judge reads each " + "rubric body literally. To lift a failing rubric you must instruct the " + "agent to visibly exhibit the quality that rubric describes.") + + +def build_metric_reference_doc(eval_config: EvalConfig) -> str: + """Render the metric reference doc as markdown. + + Builds one section per user-configured criterion-based metric (skipping + tool-call and algorithm-fixed metrics). Order is preserved from the user's + configuration. Returns the header alone when no metric is eligible — the + caller still gets a valid doc to inject. + """ + metrics = eval_config.get_eval_metrics() + included = [m for m in metrics if m.metric_name not in _SKIPPED_METRICS] + + if not included: + return _HEADER + "\n\n_No graded metrics with criterion config are registered._\n" + + sections = [_HEADER] + for metric in included: + sections.append(build_metric_section(metric)) + sections.append(_FOOTER_GUIDELINES) + + return "\n\n".join(sections) + + +def build_metric_section(metric: EvalMetric) -> str: + """Render a single metric's section. + + Public to keep tests focused: the section is also unit-testable + independently of the surrounding header/footer. + """ + name = metric.metric_name + threshold = float(metric.threshold) + criterion = metric.criterion or {} + + lines: list[str] = [] + lines.append(f"### Metric: `{name}`") + lines.append("") + lines.append(f"**Type**: {_metric_type(name)}") + description = _METRIC_DESCRIPTIONS.get(name) + if description: + lines.append(f"**Description**: {description}") + lines.append("") + + lines.append("**Scoring algorithm**:") + if name == PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + lines.extend(_render_final_response_criterion(criterion, metric_name=name)) + elif name in { + PrebuiltMetrics.LLM_FINAL_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, + }: + lines.extend(_render_llm_judge_criterion(criterion, metric_name=name)) + lines.append("") + + lines.append("**Score range**: 0.0 ~ 1.0") + lines.append(f"**PASS condition**: score >= {threshold:.4f}") + if name in { + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, + }: + n_rubrics = _count_rubrics(criterion) + if n_rubrics > 0: + min_pass = math.ceil(threshold * n_rubrics) + lines.append(f" - With {n_rubrics} rubric item(s), at least **{min_pass}** must pass.") + lines.append("") + + lines.append("**Per-case feedback contains**:") + lines.extend(_render_feedback_fields(name)) + lines.append("") + + lines.append("**What reflection LM should know**:") + lines.extend(_render_reflection_hints(name, criterion)) + + return "\n".join(lines) + + +def _metric_type(name: str) -> str: + if name == PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + return "criterion-based (deterministic text and/or JSON match)" + if name == PrebuiltMetrics.LLM_FINAL_RESPONSE.value: + return "LLM-judged binary (valid/invalid)" + if name == PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value: + return "LLM-judged rubric scoring (multiple sub-rubrics, score is the mean)" + if name == PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value: + return "LLM-judged rubric scoring over knowledge-retrieval tool outputs" + return "custom" + + +def _render_final_response_criterion(criterion: dict, *, metric_name: str) -> list[str]: + out: list[str] = [] + + if _has_custom_compare(metric_name): + out.append("- **Custom compare function**: registered via " + "``EVALUATOR_REGISTRY.set_criterion_compare``. This callable " + "**overrides** all built-in text/JSON strategies below — the " + "agent's output is judged purely by user code.") + return out + + fr = _pick(criterion, "final_response", "finalResponse") + if not isinstance(fr, dict) or not fr: + out.append("- _No ``final_response`` config provided; the metric will return 0.0 (FAIL)._") + return out + + text = _pick(fr, "text", "text_strategy", "textStrategy") + json_cfg = _pick(fr, "json", "json_strategy", "jsonStrategy") + + if isinstance(text, dict): + out.extend(_render_text_strategy(text)) + if isinstance(json_cfg, dict): + out.extend(_render_json_strategy(json_cfg)) + + if isinstance(text, dict) and isinstance(json_cfg, dict): + out.append("- **Combined**: both text and JSON checks must pass (AND logic). " + "A single failing check fails the case.") + + if not isinstance(text, dict) and not isinstance(json_cfg, dict): + out.append("- _Neither text nor JSON strategy configured; the metric will FAIL by default._") + + return out + + +def _render_text_strategy(text: dict) -> list[str]: + match = str(text.get("match") or text.get("match_strategy") or "exact").strip().lower() + case_insensitive = bool(text.get("case_insensitive") or text.get("caseInsensitive")) + ignored = bool(text.get("ignore")) + + if ignored: + return ["- **Text comparison**: ``ignore=True`` — text check is skipped (always passes)"] + + mode_desc = { + "exact": "actual output must be **byte-equal** to expected", + "contains": "actual output must **contain** expected as a substring", + "regex": "expected is treated as a **regular expression**; matched via ``re.search``", + }.get(match, f"``{match}``") + case_note = "case-insensitive" if case_insensitive else "case-sensitive" + + return [f"- **Text comparison** (``match=\"{match}\"``, {case_note}): {mode_desc}"] + + +def _render_json_strategy(json_cfg: dict) -> list[str]: + if bool(json_cfg.get("ignore")): + return ["- **JSON comparison**: ``ignore=True`` — JSON check is skipped"] + + out = ["- **JSON comparison**: actual and expected are parsed as JSON, then compared structurally"] + ignore_tree = _pick(json_cfg, "ignore_tree", "ignoreTree") + tolerance = _pick(json_cfg, "number_tolerance", "numberTolerance") + if isinstance(ignore_tree, dict) and ignore_tree: + out.append(f" - Keys ignored before compare (``ignore_tree``): ``{ignore_tree}``") + if tolerance is not None: + out.append(f" - Numeric tolerance: {tolerance}") + else: + out.append(" - Numeric tolerance: 1e-6 (default)") + return out + + +def _render_llm_judge_criterion(criterion: dict, *, metric_name: str) -> list[str]: + out: list[str] = [] + + llm = _pick(criterion, "llm_judge", "llmJudge") + if not isinstance(llm, dict) or not llm: + out.append("- _No ``llm_judge`` config provided; the metric will fail to evaluate._") + return out + + single = _pick(llm, "judge_model", "judgeModel") + multi = _pick(llm, "judge_models", "judgeModels") + + if isinstance(multi, list) and multi: + out.append(f"- **Judge models** ({len(multi)} judges, each scores independently):") + for jm in multi: + if isinstance(jm, dict): + out.append(" - " + _format_judge_model(jm)) + agg = str(_pick(llm, "models_aggregator", "modelsAggregator") or "all_pass") + agg_expl = _AGGREGATOR_EXPLANATIONS.get(agg, "custom aggregator (registered separately).") + out.append(f"- **Cross-model aggregator** (``{agg}``): {agg_expl}") + parallel = llm.get("parallel", True) + par_text = ("yes (judges run concurrently)" if parallel else "no (judges run sequentially)") + out.append(f"- **Parallel execution**: {par_text}") + elif isinstance(single, dict): + out.append(f"- **Judge model**: {_format_judge_model(single)}") + else: + out.append("- _No judge model configured._") + + rubrics = llm.get("rubrics") or [] + if isinstance(rubrics, list) and rubrics: + out.append(f"- **Rubric items** ({len(rubrics)} items judged independently, each scored 0 or 1; " + "overall score = mean of sub-scores):") + for i, rubric in enumerate(rubrics, 1): + if not isinstance(rubric, dict): + continue + rid = rubric.get("id", f"rubric_{i}") + desc = rubric.get("description", "") + content = rubric.get("content") or {} + body = content.get("text", "") if isinstance(content, dict) else "" + head = f" {i}. **``{rid}``**" + if desc: + head += f" — {desc}" + out.append(head) + if body: + out.append(f" > {body}") + + if metric_name == PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value: + knowledge_tools = _pick(llm, "knowledge_tool_names", "knowledgeToolNames") + if isinstance(knowledge_tools, list) and knowledge_tools: + out.append("- **Knowledge tools** (judge inspects results from these tool calls): " + f"``{', '.join(knowledge_tools)}``") + else: + out.append("- **Knowledge tools**: default knowledge tool set is used (no override).") + + return out + + +def _format_judge_model(jm: dict) -> str: + model = jm.get("model_name") or jm.get("modelName") or "" + extras: list[str] = [] + + num_samples = jm.get("num_samples") or jm.get("numSamples") + if isinstance(num_samples, int) and num_samples > 1: + extras.append(f"num_samples={num_samples}") + + gen = jm.get("generation_config") or jm.get("generationConfig") or {} + if isinstance(gen, dict): + if "temperature" in gen: + extras.append(f"temperature={gen['temperature']}") + mt = gen.get("max_tokens") or gen.get("maxTokens") + if mt is not None: + extras.append(f"max_tokens={mt}") + + weight = jm.get("weight") + if isinstance(weight, (int, float)) and float(weight) != 1.0: + extras.append(f"weight={weight}") + + think = jm.get("think") + if think is True: + extras.append("think=True") + elif think is False: + extras.append("think=False") + + base = f"``{model}``" + if extras: + return f"{base} ({', '.join(extras)})" + return base + + +def _render_feedback_fields(metric_name: str) -> list[str]: + out = ["- ``metric_name``, ``status`` (PASSED/FAILED), ``score``, ``threshold`` — always present"] + if metric_name == PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + out.append("- ``reason`` — short string (deterministic comparator; synthesized " + "from the criterion config when the matcher leaves it empty)") + return out + + out.append("- ``reason`` — natural-language explanation written by the LLM judge") + if metric_name in { + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, + }: + out.append("- ``rubric_scores`` — per-rubric breakdown; each item has ``id``, " + "``score``, and a ``reason`` written by the judge") + out.append("- ``per_model_scores`` (when multiple judge_models are configured) — " + "each judge's independent score/reason") + return out + + +def _render_reflection_hints(metric_name: str, criterion: dict) -> list[str]: + out: list[str] = [] + + if metric_name == PrebuiltMetrics.FINAL_RESPONSE_AVG_SCORE.value: + if _has_custom_compare(metric_name): + out.append("- Matching is delegated to user-provided Python code; format " + "requirements depend entirely on that comparator.") + return out + fr = _pick(criterion, "final_response", "finalResponse") or {} + text = fr.get("text") if isinstance(fr, dict) else None + match = "" + if isinstance(text, dict): + match = str(text.get("match") or text.get("match_strategy") or "exact").lower() + if match == "exact": + out.append("- Output must be **byte-exact**: stray whitespace or punctuation will FAIL.") + out.append("- Prompt should constrain the agent to emit *only* the expected literal text " + "with no extra prose or formatting.") + elif match == "contains": + out.append("- Output must literally **contain** the expected substring.") + out.append("- Prompt should drive the agent to emit that substring with correct " + "word order, punctuation, and units.") + elif match == "regex": + out.append("- Output is tested with ``re.search``; ensure the agent's response " + "satisfies the regex (think about how greediness and anchoring affect matching).") + if isinstance(fr, dict) and (fr.get("json") or fr.get("json_strategy") or fr.get("jsonStrategy")): + out.append("- JSON comparison is active; when the agent's output is parsed as JSON, " + "structural equality (after ``ignore_tree`` removal) matters.") + return out + + if metric_name == PrebuiltMetrics.LLM_FINAL_RESPONSE.value: + out.append("- The LLM judge gives a holistic verdict; read its ``reason`` for what swayed it.") + out.append("- Align the prompt with the qualities the judge consistently rewards.") + return out + + if metric_name in { + PrebuiltMetrics.LLM_RUBRIC_RESPONSE.value, + PrebuiltMetrics.LLM_RUBRIC_KNOWLEDGE_RECALL.value, + }: + out.append("- The judge reads each rubric body **literally**. To lift a failing rubric, " + "the agent's output must visibly satisfy what that rubric describes.") + out.append("- Do NOT remove qualities currently scoring 1.0 — examine the passing " + "rubrics in the feedback and keep their requirements in your new prompt.") + out.append("- When a rubric is being judged unfairly, prompt the agent to call out " + "the relevant quality explicitly so the judge cannot miss it.") + return out + + +def _count_rubrics(criterion: dict) -> int: + llm = _pick(criterion, "llm_judge", "llmJudge") or {} + if not isinstance(llm, dict): + return 0 + rubrics = llm.get("rubrics") or [] + if not isinstance(rubrics, list): + return 0 + return len(rubrics) + + +def _has_custom_compare(metric_name: str) -> bool: + """Detect whether a user-registered custom compare callable is present. + + Reads the registry's internal map by getattr (no public accessor exists); + falls back to ``False`` if the attribute is missing or non-mapping. + """ + registry = getattr(EVALUATOR_REGISTRY, "_criterion_compares", None) + if not isinstance(registry, dict): + return False + return metric_name in registry + + +_REFLECTION_PROMPT_PREFIX = ("I provided an assistant with the following instruction(s):\n" + "```\n\n```\n") + +_REFLECTION_PROMPT_MID_WITH_DOC = ( + "\n\nThe assistant's output is graded by the metrics described below. " + "READ THEM CAREFULLY — every per-case feedback row references one of these metrics.\n\n") + +_REFLECTION_PROMPT_MID_BARE = ("\n\nBelow are example inputs, the assistant's responses, and per-case feedback " + "summarising how each metric scored the response.\n\n") + +_REFLECTION_PROMPT_FEEDBACK = ("## How to read each example\n\n" + "Every ``# Example N`` block below is a failed case rendered by GEPA " + "as nested markdown headers. The non-self-evident fields:\n\n" + "- ``## score`` is the case-level aggregate on [0, 1] (every metric, " + "every turn, every run rolled into one number); ``1.0`` would mean " + "every metric passed, so all examples here have ``score < 1.0``.\n" + "- ``## Case Body`` — a turn-sliced markdown block; the bulk of the " + "evidence lives here. Format described below.\n" + "- ``## Other Active Components`` *(present iff the candidate has " + "more than one prompt)* — the current text of every prompt OTHER " + "than the one you are about to rewrite (the target prompt is the " + "code-fenced block at the very top of this message). The verdict " + "you see was produced by the agent running with all prompts active, " + "so use these to:\n" + " · avoid restating requirements already enforced elsewhere;\n" + " · avoid contradicting another prompt's instructions;\n" + " · spot gaps that no prompt currently covers.\n" + "- ``## history_top_k`` *(optional, present iff the case has prior " + "high-score runs from earlier candidates)* — a small list of " + "``{score, best_response}`` entries showing what previously scored well " + "on this case. Treat these as anchors: a rewrite that preserves the " + "pattern that produced those high scores is preferable to one that " + "regresses cases the optimizer already solved before.\n\n" + "## Case Body layout\n\n" + "``Case Body`` is a free-text markdown block. Each turn is one " + "``### Turn N`` section containing the conversational truth, the " + "agent's actual behaviour, and the per-turn verdict — kept together " + "so each failing metric is visually anchored to the turn that " + "produced it. Inside one turn:\n\n" + "```\n" + "### Turn N\n" + "**User**: \n" + "**Expected**: \n" + "**Agent Response**: \n" + "**Tool Trace**: (omitted if no tools were used)\n" + "- (=, ...) → [id=]\n" + "**Verdict** (Turn N):\n" + " [PASSED|FAILED] : score=, threshold=\n" + " reason: \n" + " · rubric[]: PASS|FAIL score= reason: \n" + "```\n\n" + "Multi-run cases (``num_runs > 1``) nest each run inside the turn:\n\n" + "```\n" + "### Turn N\n" + "**User**: ...\n" + "**Expected**: ...\n" + "\n" + "#### Run 1\n" + "**Agent Response**: ...\n" + "**Tool Trace**: ...\n" + "**Verdict** (Turn N, Run 1):\n" + " ...\n" + "\n" + "#### Run 2\n" + "...\n" + "```\n\n" + "Multi-turn or multi-run cases close with an ``### Overall`` block " + "(``### Overall (case-level aggregate)`` for single-run, " + "``### Overall (per-run aggregate)`` for multi-run). Single-turn " + "single-run cases skip the Overall block because Turn 1 already " + "carries the only verdict that exists.\n\n" + "## Reading rules\n\n" + "- The reference answer ONLY appears in ``**Expected**``; it is " + "deliberately not echoed inside the Verdict line, so do not look for " + "it there.\n" + "- Every ```` in a Verdict line maps directly to a " + "``### Metric: `` section in the Metrics Reference above " + "— consult it for how the score is computed before deciding what to " + "change.\n" + "- Treat PASSING metrics as constraints, not noise: a rewrite that " + "fixes a FAILING metric while regressing a PASSING one is a " + "regression, not an improvement.\n\n" + "Examples follow:\n" + "```\n\n```\n\n" + "Read each example end-to-end, then rewrite the instruction so PASSING " + "metrics stay passing and FAILING metrics improve. Provide the new " + "instruction inside ``` blocks.\n") + + +def build_reflection_prompt_template(metric_reference_doc: str) -> str: + """Build the prompt template handed to GEPA's reflection LM. + + GEPA fills ```` with the current prompt text and ```` + with the rendered per-case feedback. The metric reference doc is wedged + between them so the LM has: (1) the current prompt, (2) a static metric + syllabus, (3) live per-case feedback, in that order. + + GEPA's ``InstructionProposalSignature.validate_prompt_template`` enforces + that both placeholders are present, so we always keep them — even when + ``metric_reference_doc`` is empty. + """ + doc = (metric_reference_doc or "").strip() + if doc: + middle = _REFLECTION_PROMPT_MID_WITH_DOC + doc + "\n\n" + else: + middle = _REFLECTION_PROMPT_MID_BARE + return _REFLECTION_PROMPT_PREFIX + middle + _REFLECTION_PROMPT_FEEDBACK + + +def _pick(d: dict, *keys: str) -> Optional[Any]: + """Return the first present value among ``keys`` (handles camelCase/snake_case aliases).""" + if not isinstance(d, dict): + return None + for k in keys: + if k in d: + return d[k] + return None diff --git a/trpc_agent_sdk/evaluation/_optimize_model_callable.py b/trpc_agent_sdk/evaluation/_optimize_model_callable.py new file mode 100644 index 000000000..9465dce16 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_model_callable.py @@ -0,0 +1,309 @@ +# 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. +"""Synchronous LLM callable for optimizer prompt-rewrite operations. + +Conforms to gepa's ``LanguageModel`` Protocol so the same instance +serves as ``reflection_lm`` for ``gepa.optimize``. Internally drives a +framework :class:`LlmAgent` so optimize-model configuration honours +the framework's provider routing, env-variable expansion, and +``extra_fields`` pass-through. +""" + +from __future__ import annotations + +import asyncio +import copy +import os +import uuid +from typing import Any +from typing import Optional +from typing import Union + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import create_agent_context +from trpc_agent_sdk.context import new_invocation_context_id +from trpc_agent_sdk.models import ModelRegistry +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.planners import BuiltInPlanner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import HttpOptions +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import ThinkingConfig + +from ._optimize_model_options import OptimizeModelOptions + +DEFAULT_OPTIMIZE_MAX_TOKENS = 4096 +DEFAULT_OPTIMIZE_TEMPERATURE = 0.8 + + +def _expand_env(s: str) -> str: + """Expand environment variables in a string (e.g. $VAR or ${VAR}).""" + if not s or not isinstance(s, str): + return s or "" + return os.path.expandvars(s) + + +def _merge_extra_body( + http_options: Optional[HttpOptions], + patch: dict[str, Any], +) -> HttpOptions: + """Deep-merge patch into http_options.extra_body at nested-dict granularity.""" + base = (http_options.extra_body or {}) if http_options is not None else {} + merged: dict[str, Any] = dict(base) + for key, patch_val in patch.items(): + base_val = merged.get(key) + if isinstance(base_val, dict) and isinstance(patch_val, dict): + new_child = dict(base_val) + for subkey, subval in patch_val.items(): + new_child[subkey] = copy.deepcopy(subval) + merged[key] = new_child + else: + merged[key] = copy.deepcopy(patch_val) + if http_options is None: + return HttpOptions(extra_body=merged) + return http_options.model_copy(update={"extra_body": merged}) + + +def _create_optimize_model(opts: OptimizeModelOptions) -> Any: + """Build the underlying LLM model for an optimizer's LLM-driven operations. + + Provider routing: + - provider_name empty or "openai" -> OpenAIModel(...) directly. This + matches the framework's standard pattern for OpenAI-compatible + endpoints and forwards http_options.extra_body to the backend. + - Any other provider_name -> ModelRegistry.create_model("{provider}/{model}") + which routes to LiteLLMModel for multi-provider support. + """ + provider_name = _expand_env(opts.provider_name or "") + model_name = _expand_env(opts.model_name or "") + base_url = _expand_env(opts.base_url or "") + api_key = _expand_env(opts.api_key or "") + extra = dict(opts.extra_fields or {}) + + if not provider_name or provider_name.lower() == "openai": + return OpenAIModel( + model_name=model_name, + api_key=api_key, + base_url=base_url or None, + **extra, + ) + + return ModelRegistry.create_model( + f"{provider_name}/{model_name}", + api_key=api_key, + base_url=base_url or "", + **extra, + ) + + +# yapf: disable +def _build_optimize_generation_config( + opts: OptimizeModelOptions, +) -> tuple[GenerateContentConfig, Optional[ThinkingConfig]]: + # yapf: enable + """Build (GenerateContentConfig, ThinkingConfig | None) from OptimizeModelOptions. + + Returns thinking_config separately because LlmAgent rejects it on + GenerateContentConfig and requires it via BuiltInPlanner. + + Resolution order: + 1. Base fields (max_tokens/temperature/top_p/stop/...) from generation_config. + 2. thinking_config dict -> candidate ThinkingConfig (not written to cfg). + 3. http_options dict -> cfg.http_options (if present). + 4. opts.think overrides both paths when set. + """ + gen = opts.generation_config or {} + cfg = GenerateContentConfig() + cfg.max_output_tokens = (gen.get("max_tokens") or gen.get("max_output_tokens") or DEFAULT_OPTIMIZE_MAX_TOKENS) + cfg.temperature = gen.get("temperature", DEFAULT_OPTIMIZE_TEMPERATURE) + if "top_p" in gen and gen["top_p"] is not None: + cfg.top_p = gen["top_p"] + if "stop" in gen and gen["stop"] is not None: + cfg.stop_sequences = (gen["stop"] if isinstance(gen["stop"], list) else [gen["stop"]]) + elif "stop_sequences" in gen and gen["stop_sequences"] is not None: + cfg.stop_sequences = gen["stop_sequences"] + if "presence_penalty" in gen and gen["presence_penalty"] is not None: + setattr(cfg, "presence_penalty", gen["presence_penalty"]) + if "frequency_penalty" in gen and gen["frequency_penalty"] is not None: + setattr(cfg, "frequency_penalty", gen["frequency_penalty"]) + + effective_thinking_config: Optional[ThinkingConfig] = None + tc_dict = gen.get("thinking_config") + if isinstance(tc_dict, dict): + effective_thinking_config = ThinkingConfig(**tc_dict) + + http_opts_dict = gen.get("http_options") + if isinstance(http_opts_dict, dict): + cfg.http_options = HttpOptions(**http_opts_dict) + + if opts.think is True: + effective_thinking_config = ThinkingConfig( + include_thoughts=True, + thinking_budget=-1, + ) + cfg.http_options = _merge_extra_body( + cfg.http_options, + {"chat_template_kwargs": { + "enable_thinking": True + }}, + ) + elif opts.think is False: + effective_thinking_config = ThinkingConfig( + include_thoughts=False, + thinking_budget=0, + ) + cfg.http_options = _merge_extra_body( + cfg.http_options, + {"chat_template_kwargs": { + "enable_thinking": False + }}, + ) + + return cfg, effective_thinking_config + + +def _extract_final_text(event: Any) -> str: + """Collect non-thought text from a single LlmAgent final-response event. + + Returns empty string when the event is not a final response, lacks content, + or contains only thought parts. + """ + if not event.is_final_response(): + return "" + if not event.content or not event.content.parts: + return "" + return "\n".join((p.text or "").strip() for p in event.content.parts if p.thought is not True).strip() + + +def _flatten_messages(prompt: Union[str, list[dict[str, Any]]]) -> str: + """Flatten gepa's prompt forms into a single user-text string. + + Accepts: + - str: returned verbatim + - list[dict]: messages with role/content; joined with role tags so the + downstream LlmAgent receives a single user turn that preserves the + original conversation structure + """ + if isinstance(prompt, str): + return prompt + if not isinstance(prompt, list): + return str(prompt) + parts: list[str] = [] + for msg in prompt: + if not isinstance(msg, dict): + parts.append(str(msg)) + continue + role = msg.get("role", "user") + content = msg.get("content", "") + if isinstance(content, list): + content = "".join(c.get("text", str(c)) for c in content if isinstance(c, dict)) + parts.append(f"[{role}]\n{content}") + return "\n\n".join(parts) + + +class _OptimizeModelCallable: + """Synchronous LLM callable wrapping a framework `LlmAgent`. + + Conforms to gepa's `LanguageModel` Protocol: + - `__call__(prompt: str | list[dict]) -> str` + - `total_cost: float` attribute (used by gepa's MaxReflectionCostStopper) + + LlmAgent topology: instruction = "" (callers embed their own system text + inside the prompt), single user turn, no tools, no planner unless + `think` requests one, output_schema = None. + """ + + def __init__(self, opts: OptimizeModelOptions) -> None: + model = _create_optimize_model(opts) + cfg, thinking_config = _build_optimize_generation_config(opts) + planner = (BuiltInPlanner(thinking_config=thinking_config) if thinking_config is not None else None) + self._agent = LlmAgent( + name="optimize_model", + model=model, + instruction="", + generate_content_config=cfg, + add_name_to_instruction=False, + output_schema=None, + tools=[], + planner=planner, + ) + self._session_service = InMemorySessionService() + self.total_cost: float = 0.0 + self.total_calls: int = 0 + self.total_token_usage: dict[str, int] = { + "prompt": 0, + "completion": 0, + "total": 0, + } + + def __call__(self, prompt: Union[str, list[dict[str, Any]]]) -> str: + user_text = _flatten_messages(prompt) + self.total_calls += 1 + return asyncio.run(self._run_async(user_text)) + + async def _run_async(self, user_text: str) -> str: + user_content = Content(role="user", parts=[Part.from_text(text=user_text)]) + agent_context = create_agent_context() + session = await self._session_service.create_session( + app_name="optimizer", + user_id="optimize_model", + session_id=str(uuid.uuid4()), + agent_context=agent_context, + ) + ctx = InvocationContext( + session_service=self._session_service, + invocation_id=new_invocation_context_id(), + agent=self._agent, + session=session, + agent_context=agent_context, + user_content=user_content, + override_messages=[user_content], + ) + last_text = "" + async for event in self._agent.run_async(ctx): + part_text = _extract_final_text(event) + if part_text: + last_text += part_text + usage = getattr(event, "usage_metadata", None) + if usage is not None: + self._accumulate_usage(usage) + return last_text.strip() + + def _accumulate_usage(self, usage: Any) -> None: + """Add a single ``usage_metadata`` snapshot into ``total_token_usage``. + + Tolerant to Pydantic models, dict, or arbitrary attribute-bearing + objects so it works across model providers. + """ + prompt = self._read_count(usage, ("prompt_token_count", "input_tokens", "prompt_tokens")) + completion = self._read_count( + usage, + ("candidates_token_count", "output_tokens", "completion_tokens"), + ) + total = self._read_count(usage, ("total_token_count", "total_tokens")) + if total <= 0 and (prompt > 0 or completion > 0): + total = prompt + completion + self.total_token_usage["prompt"] += prompt + self.total_token_usage["completion"] += completion + self.total_token_usage["total"] += total + + @staticmethod + def _read_count(usage: Any, names: tuple[str, ...]) -> int: + """Return the first non-None int among the candidate attribute / key names.""" + for name in names: + value = None + if isinstance(usage, dict): + value = usage.get(name) + else: + value = getattr(usage, name, None) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + return 0 diff --git a/trpc_agent_sdk/evaluation/_optimize_model_options.py b/trpc_agent_sdk/evaluation/_optimize_model_options.py new file mode 100644 index 000000000..7e15c5490 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_model_options.py @@ -0,0 +1,45 @@ +# 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. +"""LLM options for the optimizer's prompt rewrite step.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from pydantic import Field + +from ._common import EvalBaseModel + + +class OptimizeModelOptions(EvalBaseModel): + """LLM configuration for proposing new prompt candidates.""" + + provider_name: str = Field(default="", description="LLM provider name.") + model_name: str = Field(default="", description="Model name.") + variant: str = Field(default="", description="OpenAI-compatible variant when provider is openai.") + base_url: Optional[str] = Field(default=None, description="Custom endpoint URL.") + api_key: str = Field(default="", description="API key.") + extra_fields: Optional[dict[str, Any]] = Field( + default=None, + description="Extra provider-specific fields.", + ) + num_samples: Optional[int] = Field( + default=None, + description="Number of samples per call.", + ) + generation_config: Optional[dict[str, Any]] = Field( + default=None, + description="Generation params: max_tokens, temperature, stream, etc.", + ) + weight: float = Field( + default=1.0, + description="Weight for aggregation across samples.", + ) + think: Optional[bool] = Field( + default=None, + description="Thinking mode toggle. None: no change; False: disable; True: enable.", + ) diff --git a/trpc_agent_sdk/evaluation/_optimize_registrations.py b/trpc_agent_sdk/evaluation/_optimize_registrations.py new file mode 100644 index 000000000..74df6870b --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_registrations.py @@ -0,0 +1,22 @@ +# 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. +"""Central registration of optimizer algorithms. + +Each algorithm is registered under ``try/except ImportError`` so optional +third-party deps that are missing simply omit the algorithm rather than +breaking package import. +""" + +from __future__ import annotations + +from ._optimize_registry import OPTIMIZER_REGISTRY + +try: + from ._optimize_gepa_reflective import GepaReflectiveOptimizer +except ImportError: + pass +else: + OPTIMIZER_REGISTRY.register("gepa_reflective", GepaReflectiveOptimizer) diff --git a/trpc_agent_sdk/evaluation/_optimize_registry.py b/trpc_agent_sdk/evaluation/_optimize_registry.py new file mode 100644 index 000000000..d1c72398e --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_registry.py @@ -0,0 +1,41 @@ +# 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. +"""Registry mapping optimizer algorithm name to BaseOptimizer subclass.""" + +from __future__ import annotations + +import inspect +from typing import Type + +from ._base_optimizer import BaseOptimizer + + +class OptimizerRegistry: + """Maps optimizer algorithm name to a BaseOptimizer subclass.""" + + def __init__(self) -> None: + self._registry: dict[str, Type[BaseOptimizer]] = {} + + def register(self, name: str, optimizer_class: Type[BaseOptimizer]) -> None: + """Register an optimizer class under the given algorithm name.""" + if not inspect.isclass(optimizer_class) or not issubclass(optimizer_class, BaseOptimizer): + raise TypeError(f"optimizer_class must be a subclass of BaseOptimizer, " + f"got {optimizer_class!r}") + self._registry[name] = optimizer_class + + def list_registered(self) -> list[str]: + """Return sorted algorithm names currently registered.""" + return sorted(self._registry.keys()) + + def get(self, name: str) -> Type[BaseOptimizer]: + """Return the optimizer class registered under name; raise if absent.""" + if name not in self._registry: + raise ValueError(f"No optimizer registered for algorithm: {name}. " + f"Available algorithms: {self.list_registered()}") + return self._registry[name] + + +OPTIMIZER_REGISTRY = OptimizerRegistry() diff --git a/trpc_agent_sdk/evaluation/_optimize_reporter.py b/trpc_agent_sdk/evaluation/_optimize_reporter.py new file mode 100644 index 000000000..51a3ca904 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_reporter.py @@ -0,0 +1,1001 @@ +# 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. +"""Algorithm-agnostic progress sink for AgentOptimizer. + +Defines :class:`OptimizeReporter` (the surface algorithms emit progress +events to) and three concrete backends: + + * :class:`_NullReporter` drops every event (``verbose=0``). + * :class:`_RichReporter` Rich panel header, Live progress bar over + the budget, colourised round lines, closing + summary panel with per-metric comparison. + * :class:`_AsciiReporter` plain-``print`` fallback for non-Rich + environments. + +:class:`_SilentGepaLogger` is a ``gepa.LoggerProtocol``-compatible sink +the optimizer hands to gepa to keep library logs out of the reporter +timeline. + +:func:`create_reporter` picks a backend by ``verbose`` level and ``rich`` +availability. +""" + +from __future__ import annotations + +import logging +import os +import sys +from dataclasses import dataclass +from dataclasses import field +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal +from typing import Optional +from typing import Protocol +from typing import TextIO +from typing import runtime_checkable + +if TYPE_CHECKING: + from ._optimize_result import OptimizeResult + +logger = logging.getLogger(__name__) + +_GEPA_LOGGER_NAME = "trpc_agent_sdk.optimizer.gepa" + +_MAX_TARGET_FIELDS_IN_HEADER = 8 +_FIELD_NAME_DISPLAY_LIMIT = 40 + + +@dataclass(frozen=True) +class RunHeader: + """Static run context shown at run start. + + Attributes: + algorithm: Registered algorithm name (e.g. ``gepa_reflective``). + target_fields: Ordered ``(field_name, source_repr)`` pairs; + ``source_repr`` is the file path for ``add_path`` fields or + ``""`` for ``add_callback`` fields. + train_size: Training case count. + val_size: Validation case count. + metric_names: Display names of every reported metric. + output_dir: Resolved artifact directory. + budget_total: Configured metric-call budget (e.g. + ``max_metric_calls``); ``None`` falls back to an + indeterminate progress display. + """ + + algorithm: str + target_fields: list[tuple[str, str]] + train_size: int + val_size: int + metric_names: list[str] + output_dir: str + budget_total: Optional[int] = None + + +@dataclass(frozen=True) +class RoundView: + """Single-round summary for one per-round line. + + Attributes: + round: 1-based round index from the algorithm. + kind: ``"reflective"`` (default) or ``"merge"``; unknown values + render as ``"reflective"``. + train_minibatch_size: ``M`` in ``train(M/N)``; 0 when the round + skipped before sampling. + train_size: ``N`` — full training set size. + train_subsample_parent_score: Parent's score on the minibatch + (None when no subsample produced). + train_subsample_candidate_score: New candidate's score (None + when not evaluated). + val_pass_rate: Full validation pass rate when the candidate + cleared the subsample gate (None otherwise). + accepted: True iff the candidate joined the pool. + skip_reason: Human-readable reason for skipped rounds. + error_message: Set when the round ended in an error. + duration_seconds: Wall-clock seconds. + budget_used: Cumulative metric calls used (None when the + algorithm doesn't track a budget). + budget_total: Configured ``max_metric_calls`` (None means + ``"auto"``). + extras: Free-form algorithm-specific payload. + """ + + round: int + kind: Literal["reflective", "merge"] + train_minibatch_size: int + train_size: int + train_subsample_parent_score: Optional[float] + train_subsample_candidate_score: Optional[float] + val_pass_rate: Optional[float] + accepted: bool + skip_reason: Optional[str] + error_message: Optional[str] + duration_seconds: float + budget_used: Optional[int] + budget_total: Optional[int] + extras: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class OptimizeReporter(Protocol): + """Five-event surface every backend implements. + + Implementations swallow render errors; the facade also guards each + call so a broken reporter never breaks optimization. + """ + + def run_started(self, header: RunHeader) -> None: + ... + + def baseline_evaluated( + self, + pass_rate: float, + metric_breakdown: dict[str, float], + *, + metric_thresholds: Optional[dict[str, float]] = None, + ) -> None: + ... + + def round_completed(self, view: RoundView) -> None: + ... + + def run_finished( + self, + result: "OptimizeResult", + *, + output_dir: str, + update_source: bool, + ) -> None: + ... + + def run_failed( + self, + *, + baseline_prompts: dict[str, str], + output_dir: str, + error_message: str, + ) -> None: + ... + + +class _NullReporter: + """No-op reporter used when ``verbose=0``.""" + + def run_started(self, header: RunHeader) -> None: + return None + + def baseline_evaluated( + self, + pass_rate: float, + metric_breakdown: dict[str, float], + *, + metric_thresholds: Optional[dict[str, float]] = None, + ) -> None: + return None + + def round_completed(self, view: RoundView) -> None: + return None + + def run_finished( + self, + result: "OptimizeResult", + *, + output_dir: str, + update_source: bool, + ) -> None: + return None + + def run_failed( + self, + *, + baseline_prompts: dict[str, str], + output_dir: str, + error_message: str, + ) -> None: + return None + + +def _truncate(text: str, limit: int) -> str: + """Return ``text`` shortened to at most ``limit`` characters with ellipsis.""" + if len(text) <= limit: + return text + return text[:max(0, limit - 3)] + "..." + + +def _format_source(source_repr: str) -> str: + """Compact a target source for display in the run header. + + File-backed sources collapse to their basename (full path remains in + ``config.snapshot.json`` / ``result.json``); callback sources keep their + sentinel ```` form. + """ + if source_repr == "": + return source_repr + return os.path.basename(source_repr) or source_repr + + +def _format_sample_score_segment(view: RoundView, *, ascii_only: bool) -> str: + """Render the ``sample score parent → candidate`` segment, or empty when absent.""" + parent = view.train_subsample_parent_score + candidate = view.train_subsample_candidate_score + if parent is None and candidate is None: + return "" + arrow = "->" if ascii_only else "→" + if parent is None: + return f"sample score {candidate:.2f}" + if candidate is None: + return f"sample score {parent:.2f}" + return f"sample score {parent:.2f} {arrow} {candidate:.2f}" + + +def _format_evaluations_segment(view: RoundView) -> str: + """Render the trailing ``evaluations used/total`` segment, or empty when not tracked.""" + if view.budget_used is None: + return "" + total = "auto" if view.budget_total is None else str(view.budget_total) + return f"evaluations {view.budget_used}/{total}" + + +def _round_marker(view: RoundView, *, ascii_only: bool) -> str: + """Return the leading marker glyph for a round line. + + Glyph → meaning mapping (kept identical between ASCII and Rich): + + * ``✓`` accepted — candidate beat the current best on valset. + * ``○`` explored — full valset evaluation ran but did not improve. + * ``·`` skipped — subsample gate / no-proposal / cache hit etc. + * ``↻`` merge — gepa system-aware merge round. + * ``✗`` error — round ended in an algorithm error. + """ + if view.error_message: + return "x" if ascii_only else "✗" + if view.skip_reason: + return "." if ascii_only else "·" + if view.kind == "merge": + return "~" if ascii_only else "↻" + if view.accepted: + return "OK" if ascii_only else "✓" + return "-" if ascii_only else "○" + + +def _round_status_word(view: RoundView) -> str: + """Return the textual status label rendered next to the round marker.""" + if view.error_message: + return "error" + if view.skip_reason: + return "skipped" + if view.kind == "merge": + return "merged" if view.accepted else "merge" + if view.accepted: + return "accepted" + return "explored" + + +def _format_stop_reason_text(stop_reason: Optional[str]) -> Optional[str]: + """Translate ``OptimizeResult.stop_reason`` into the reporter row text. + + Returns ``None`` when no row should be emitted (i.e. the run errored + before any stopper could classify a reason). + """ + if stop_reason is None: + return None + text_by_reason = { + "required_metrics_passing": "required metrics met thresholds", + "budget_exhausted": "budget exhausted (max_metric_calls reached)", + "no_improvement": "no improvement for the configured number of rounds", + "timeout": "timeout reached", + "score_threshold": "score threshold reached", + "max_candidate_proposals": "max candidate proposals reached", + "max_tracked_candidates": "max tracked candidates reached", + "user_requested_stop": "user requested stop (optimize.stop touched)", + "completed": "completed (no stopper triggered)", + } + return text_by_reason.get(stop_reason, stop_reason) + + +def _round_legend_lines(*, ascii_only: bool) -> list[str]: + """Return the static legend block describing round-line semantics. + + Printed once between header and baseline so users can decode every + subsequent round line without scrolling back. + """ + arrow = "->" if ascii_only else "→" + accepted = "OK" if ascii_only else "✓" + explored = "-" if ascii_only else "○" + skipped = "." if ascii_only else "·" + merge = "~" if ascii_only else "↻" + error = "x" if ascii_only else "✗" + return [ + "Round line legend:", + f" format : round N train sample M/N " + f"sample score parent {arrow} candidate " + f"valset pass_rate Z evaluations used/total duration", + f" status : {accepted} accepted {explored} explored " + f"{skipped} skipped {merge} merge {error} error", + " train : a minibatch of M cases sampled from the N-case training set " + "for the reflective step.", + " sample : parent vs new candidate score on that minibatch " + "(skip gate decides whether to run valset).", + " valset : pass_rate over the full validation set when the candidate " + "cleared the skip gate.", + " budget : evaluations used / configured budget (metric calls).", + ] + + +def _improvement_arrow(delta: float, *, ascii_only: bool) -> str: + """Return the directional arrow for a pass-rate delta.""" + if delta > 0: + return "^" if ascii_only else "▲" + if delta < 0: + return "v" if ascii_only else "▼" + return "=" + + +def _format_improvement_label(delta: float) -> str: + """Return a textual label describing the improvement direction.""" + if delta > 0: + return "improved" + if delta < 0: + return "regressed" + return "no improvement" + + +def _format_round_line(view: RoundView, *, ascii_only: bool) -> str: + """Render a single-line per-round summary in ASCII form. + + Layout: `` round N train sample M/N sample score X -> Y + evaluations U/T ``. Segments + that do not apply to the current round (e.g. ``sample score`` for skipped + rounds without subsample data) are omitted. + """ + marker = _round_marker(view, ascii_only=ascii_only) + status_word = _round_status_word(view) + head = f"{marker} round {view.round} {status_word}" + + segments: list[str] = [] + if view.train_minibatch_size > 0: + segments.append(f"train sample {view.train_minibatch_size}/{view.train_size}") + sample = _format_sample_score_segment(view, ascii_only=ascii_only) + if sample: + segments.append(sample) + + if view.error_message: + segments.append(f"message: {view.error_message}") + elif view.skip_reason: + segments.append(f"reason: {view.skip_reason}") + elif view.val_pass_rate is not None: + segments.append(f"valset pass_rate {view.val_pass_rate:.4f}") + + evaluations = _format_evaluations_segment(view) + if evaluations: + segments.append(evaluations) + + body = " ".join(segments) + tail = f" {view.duration_seconds:.1f}s" + return f"{head} {body}{tail}" + + +def _ordered_metric_keys(*breakdowns: dict[str, float], extra: Optional[list[str]] = None) -> list[str]: + """Stable union of metric keys across baseline/best breakdowns and an + optional ``extra`` ordering hint.""" + seen: dict[str, None] = {} + if extra: + for name in extra: + seen.setdefault(name, None) + for breakdown in breakdowns: + for name in breakdown.keys(): + seen.setdefault(name, None) + return list(seen.keys()) + + +def _format_score(value: Optional[float]) -> str: + """Return a fixed-width formatted metric score, or ``-`` when missing.""" + if value is None: + return " - " + return f"{value:.4f}" + + +def _format_delta(value: float, *, ascii_only: bool) -> tuple[str, str]: + """Return a ``(arrow, text)`` pair describing a per-metric improvement.""" + arrow = _improvement_arrow(value, ascii_only=ascii_only) + sign = "+" if value >= 0 else "" + return arrow, f"{sign}{value:.4f}" + + +def _baseline_metric_status( + score: Optional[float], + threshold: Optional[float], + *, + ascii_only: bool, +) -> str: + """Return ``PASS`` / ``FAIL`` (or ``-``) based on whether ``score`` cleared the threshold. + + Mirrors evaluator semantics (``PASSED if score >= threshold``) so the + reporter never disagrees with the evaluator's own PASS / FAIL decision. + """ + if score is None or threshold is None: + return " - " + if score >= threshold: + return "PASS" if ascii_only else "PASS" + return "FAIL" if ascii_only else "FAIL" + + +class _AsciiReporter: + """Dependency-free reporter used as fallback for non-Rich environments. + + Renders every event as ordered plain text via ``print``; safe for log + files and CI pipes. Falls back to ASCII glyphs when the stream encoding + cannot represent the Unicode marker set. + """ + + def __init__(self, *, stream: TextIO = sys.stdout, verbose: int = 1) -> None: + self._stream = stream + self._verbose = verbose + self._ascii_only = self._detect_ascii_only() + + def _detect_ascii_only(self) -> bool: + """Return True when the stream encoding cannot render Unicode glyphs.""" + encoding = getattr(self._stream, "encoding", None) or sys.getdefaultencoding() + try: + "✓✗·↻▲▼○".encode(encoding) + except (LookupError, UnicodeEncodeError): + return True + return False + + def run_started(self, header: RunHeader) -> None: + lines = [ + "", + "=" * 80, + f" AgentOptimizer · {header.algorithm}", + "=" * 80, + self._format_targets_line(header.target_fields), + ] + for name, src in header.target_fields[:_MAX_TARGET_FIELDS_IN_HEADER]: + display_name = _truncate(name, _FIELD_NAME_DISPLAY_LIMIT) + lines.append(f" - {display_name:<40s} ({_format_source(src)})") + if len(header.target_fields) > _MAX_TARGET_FIELDS_IN_HEADER: + extra = len(header.target_fields) - _MAX_TARGET_FIELDS_IN_HEADER + lines.append(f" ... and {extra} more") + lines.append(f" train/val : {header.train_size} / {header.val_size} cases") + lines.append(f" metrics : {len(header.metric_names)} configured") + for name in header.metric_names: + lines.append(f" - {name}") + if header.budget_total is not None: + lines.append(f" budget : {header.budget_total} metric calls") + else: + lines.append(" budget : auto (no explicit cap)") + lines.append(f" output_dir : {header.output_dir}") + lines.append("-" * 80) + lines.append("") + lines.extend(_round_legend_lines(ascii_only=self._ascii_only)) + lines.append("") + self._writelines(lines) + + @staticmethod + def _format_targets_line(target_fields: list[tuple[str, str]]) -> str: + if len(target_fields) == 1: + return " target : 1 field" + return f" targets : {len(target_fields)} fields" + + def baseline_evaluated( + self, + pass_rate: float, + metric_breakdown: dict[str, float], + *, + metric_thresholds: Optional[dict[str, float]] = None, + ) -> None: + thresholds = metric_thresholds or {} + lines = [f"baseline pass_rate = {pass_rate:.4f}"] + keys = _ordered_metric_keys(metric_breakdown, extra=list(thresholds.keys())) + if keys: + lines.append(" per-metric (threshold | score | status):") + for name in keys: + score = metric_breakdown.get(name) + threshold = thresholds.get(name) + status = _baseline_metric_status(score, threshold, ascii_only=self._ascii_only) + threshold_str = (f"{threshold:.4f}" if threshold is not None else " - ") + score_str = _format_score(score) + lines.append(f" - {name:<40s} threshold {threshold_str} " + f"{score_str} {status}") + lines.append("") + self._writelines(lines) + + def round_completed(self, view: RoundView) -> None: + self._writelines([_format_round_line(view, ascii_only=self._ascii_only)]) + + def run_finished( + self, + result: "OptimizeResult", + *, + output_dir: str, + update_source: bool, + ) -> None: + self._writelines([""]) + self._writelines(self._build_summary_lines( + result=result, + output_dir=output_dir, + update_source=update_source, + )) + + def run_failed( + self, + *, + baseline_prompts: dict[str, str], + output_dir: str, + error_message: str, + ) -> None: + self._writelines([ + "", + "=" * 80, + " Optimization FAILED", + "=" * 80, + f" error : {error_message}", + f" output_dir : {output_dir}", + f" baseline preserved at {os.path.join(output_dir, 'baseline_prompts')}", + "=" * 80, + "", + ]) + + def _build_summary_lines( + self, + *, + result: "OptimizeResult", + output_dir: str, + update_source: bool, + ) -> list[str]: + """Return the multi-line summary block printed at run finish.""" + arrow = _improvement_arrow(result.pass_rate_improvement, ascii_only=self._ascii_only) + label = _format_improvement_label(result.pass_rate_improvement) + accepted = sum(1 for r in result.rounds if r.accepted) + sign = "+" if result.pass_rate_improvement >= 0 else "" + rate_line = (f" pass_rate : {result.baseline_pass_rate:.4f} -> {result.best_pass_rate:.4f}" + f" {arrow} {sign}{result.pass_rate_improvement:.4f} ({label})") + lines = [ + "=" * 80, + f" Optimization complete · {result.status}", + "=" * 80, + rate_line, + f" rounds : {accepted} accepted / {result.total_rounds} total", + f" duration : {result.duration_seconds:.2f}s", + ] + stop_text = _format_stop_reason_text(result.stop_reason) + if stop_text is not None: + lines.append(f" stopped by : {stop_text}") + if result.status != "SUCCEEDED" and result.error_message: + lines.append(f" error : {result.error_message}") + metric_keys = _ordered_metric_keys( + result.baseline_metric_breakdown, + result.best_metric_breakdown, + extra=list(result.metric_thresholds.keys()), + ) + if metric_keys: + lines.append(" per-metric : threshold | baseline -> best | delta | status") + for name in metric_keys: + base = result.baseline_metric_breakdown.get(name) + best = result.best_metric_breakdown.get(name) + threshold = result.metric_thresholds.get(name) + delta = (best or 0.0) - (base or 0.0) + d_arrow, d_text = _format_delta(delta, ascii_only=self._ascii_only) + base_str = _format_score(base) + best_str = _format_score(best) + threshold_str = (f"{threshold:.4f}" if threshold is not None else " - ") + status = _baseline_metric_status(best, threshold, ascii_only=self._ascii_only) + lines.append(f" - {name:<40s} threshold {threshold_str} " + f"{base_str} -> {best_str} {d_arrow} {d_text} {status}") + update_msg = self._format_update_source_line(result=result, output_dir=output_dir, update_source=update_source) + if update_msg: + lines.append(update_msg) + lines.extend(self._format_artifacts_block(result=result, output_dir=output_dir)) + lines.append("=" * 80) + lines.append("") + return lines + + @staticmethod + def _format_update_source_line( + *, + result: "OptimizeResult", + output_dir: str, + update_source: bool, + ) -> Optional[str]: + """Return the ``update_source`` row text or ``None`` to omit it.""" + if not update_source: + best_dir = os.path.join(output_dir, "best_prompts") + return f" update_source: false (best prompts at {best_dir}/)" + if result.status == "SUCCEEDED": + return " update_source: true (best written back to target sources)" + return " update_source: true (run failed; sources restored from baseline)" + + @staticmethod + def _format_artifacts_block( + *, + result: "OptimizeResult", + output_dir: str, + ) -> list[str]: + """Return the artifact directory listing lines for the summary.""" + lines = [" artifacts :"] + lines.append(f" {output_dir}/") + for name, content in result.best_prompts.items(): + display = _truncate(name, _FIELD_NAME_DISPLAY_LIMIT) + lines.append(f" best_prompts/{display}.md ({len(content)} chars)") + lines.append(" result.json summary.txt rounds/ run.log") + return lines + + def _writelines(self, lines: list[str]) -> None: + """Write a list of lines to the stream, swallowing render errors.""" + try: + self._stream.write("\n".join(lines)) + self._stream.write("\n") + try: + self._stream.flush() + except (AttributeError, ValueError): # pragma: no cover - non-flushable buffers + pass + except Exception: # pragma: no cover - never break optimization on render error + logger.warning("AsciiReporter write failed", exc_info=True) + + +class _RichReporter: + """Rich-backed reporter that degrades to plain output on non-TTY streams. + + Uses Rich panels for the header and the closing summary, a Live region + with a progress bar over the configured metric-call budget for the + duration of the run, and a single coloured line per round. The underlying + ``rich.console.Console`` auto-detects whether the stream supports ANSI + sequences. + """ + + def __init__(self, *, stream: TextIO = sys.stdout, verbose: int = 1) -> None: + from rich.console import Console + + self._stream = stream + self._verbose = verbose + self._console = Console( + file=stream, + force_terminal=None, + highlight=False, + soft_wrap=False, + ) + self._ascii = _AsciiReporter(stream=stream, verbose=verbose) + self._progress = None + self._budget_task = None + self._budget_total: Optional[int] = None + + def run_started(self, header: RunHeader) -> None: + from rich.panel import Panel + from rich.table import Table + from rich import box + from rich.progress import ( + Progress, + BarColumn, + TextColumn, + TimeElapsedColumn, + ) + + table = Table.grid(padding=(0, 2)) + table.add_column(no_wrap=True, style="dim") + table.add_column(no_wrap=False) + + targets_label = ("target" if len(header.target_fields) == 1 else "targets") + targets_value = ("1 field" if len(header.target_fields) == 1 else f"{len(header.target_fields)} fields") + table.add_row(targets_label, targets_value) + visible = header.target_fields[:_MAX_TARGET_FIELDS_IN_HEADER] + for name, src in visible: + display_name = _truncate(name, _FIELD_NAME_DISPLAY_LIMIT) + table.add_row("", f"- {display_name} [dim]({_format_source(src)})[/dim]") + if len(header.target_fields) > len(visible): + remainder = len(header.target_fields) - len(visible) + table.add_row("", f"[dim]... and {remainder} more[/dim]") + + table.add_row("train/val", f"{header.train_size} / {header.val_size} cases") + metric_count_label = ("metric" if len(header.metric_names) == 1 else f"metrics ({len(header.metric_names)})") + table.add_row(metric_count_label, "") + for name in header.metric_names: + table.add_row("", f"- {name}") + + budget_text = (f"{header.budget_total} metric calls" + if header.budget_total is not None else "auto (no explicit cap)") + table.add_row("budget", budget_text) + table.add_row("output_dir", header.output_dir) + + panel = Panel( + table, + title=f"[bold]AgentOptimizer[/bold] · [cyan]{header.algorithm}[/cyan]", + box=box.ROUNDED, + padding=(0, 1), + ) + self._console.print(panel) + self._console.print("") + for line in _round_legend_lines(ascii_only=False): + self._console.print(f"[dim]{line}[/dim]") + self._console.print("") + + self._budget_total = header.budget_total + # ``auto_refresh=False`` keeps the Live region quiescent between + # explicit refresh calls — embedded IDE terminals and some CI + # captures don't honour rich's cursor-up escapes, so the default + # 10Hz auto-refresh would re-print the bar instead of erasing + # it. Manual refresh on each ``round_completed`` keeps the + # output bounded to one line per event. + self._progress = Progress( + TextColumn("[bold]progress[/bold]"), + BarColumn(bar_width=None), + TextColumn("{task.completed}/{task.total} metric calls"), + TextColumn("•"), + TimeElapsedColumn(), + console=self._console, + transient=False, + expand=True, + auto_refresh=False, + ) + total = header.budget_total if header.budget_total is not None else 100 + self._budget_task = self._progress.add_task("budget", total=total) + try: + self._progress.start() + self._progress.refresh() + except Exception: # pragma: no cover - Live region best-effort + self._progress = None + self._budget_task = None + + def baseline_evaluated( + self, + pass_rate: float, + metric_breakdown: dict[str, float], + *, + metric_thresholds: Optional[dict[str, float]] = None, + ) -> None: + from rich.table import Table + from rich import box + + thresholds = metric_thresholds or {} + self._console.print(f"[bold]baseline pass_rate = {pass_rate:.4f}[/bold]") + keys = _ordered_metric_keys(metric_breakdown, extra=list(thresholds.keys())) + if keys: + t = Table(box=box.SIMPLE, show_header=True, header_style="dim") + t.add_column("metric", no_wrap=True) + t.add_column("threshold", justify="right") + t.add_column("baseline", justify="right") + t.add_column("status", justify="right") + for name in keys: + score = metric_breakdown.get(name) + threshold = thresholds.get(name) + threshold_str = (f"{threshold:.4f}" if threshold is not None else "-") + score_str = (f"{score:.4f}" if score is not None else "-") + status = _baseline_metric_status(score, threshold, ascii_only=False) + color = ("green" if status == "PASS" else "red" if status == "FAIL" else "dim") + t.add_row( + name, + threshold_str, + score_str, + f"[{color}]{status}[/{color}]", + ) + self._console.print(t) + self._console.print("") + + def round_completed(self, view: RoundView) -> None: + if self._progress is not None and view.budget_used is not None: + try: + if self._budget_total is None: + # When no upper bound was set, grow the bar with usage. + self._progress.update( + self._budget_task, + completed=view.budget_used, + total=max(view.budget_used, 1), + ) + else: + self._progress.update( + self._budget_task, + completed=min(view.budget_used, self._budget_total), + ) + # Explicit refresh because ``auto_refresh=False`` keeps + # the Live region quiescent between events. + self._progress.refresh() + except Exception: # pragma: no cover + pass + + marker = _round_marker(view, ascii_only=False) + status_word = _round_status_word(view) + style = self._round_style(view) + head = (f"[{style}]{marker} round {view.round} {status_word}[/{style}]") + + segments: list[str] = [] + if view.train_minibatch_size > 0: + segments.append(f"train sample {view.train_minibatch_size}/{view.train_size}") + sample = _format_sample_score_segment(view, ascii_only=False) + if sample: + segments.append(sample) + if view.error_message: + segments.append(f"[red]message: {view.error_message}[/red]") + elif view.skip_reason: + segments.append(f"[dim]reason: {view.skip_reason}[/dim]") + elif view.val_pass_rate is not None: + segments.append(f"[green]valset pass_rate {view.val_pass_rate:.4f}[/green]") + evaluations = _format_evaluations_segment(view) + if evaluations: + segments.append(f"[dim]{evaluations}[/dim]") + body = " ".join(segments) + tail = f" [dim]{view.duration_seconds:.1f}s[/dim]" + self._console.print(f"{head} {body}{tail}") + + @staticmethod + def _round_style(view: RoundView) -> str: + """Return the Rich style string for the round marker.""" + if view.error_message: + return "bold red" + if view.skip_reason: + return "dim" + if view.accepted: + return "bold green" + return "yellow" + + def _stop_progress(self) -> None: + if self._progress is None: + return + try: + self._progress.stop() + except Exception: # pragma: no cover + pass + self._progress = None + self._budget_task = None + + def run_finished( + self, + result: "OptimizeResult", + *, + output_dir: str, + update_source: bool, + ) -> None: + from rich.panel import Panel + from rich.table import Table + from rich import box + + self._stop_progress() + + accepted = sum(1 for r in result.rounds if r.accepted) + sign = "+" if result.pass_rate_improvement >= 0 else "" + arrow = _improvement_arrow(result.pass_rate_improvement, ascii_only=False) + label = _format_improvement_label(result.pass_rate_improvement) + delta_color = ("green" + if result.pass_rate_improvement > 0 else "red" if result.pass_rate_improvement < 0 else "dim") + + table = Table.grid(padding=(0, 2)) + table.add_column(no_wrap=True, style="dim") + table.add_column(no_wrap=False) + rate_value = (f"{result.baseline_pass_rate:.4f} -> [bold]{result.best_pass_rate:.4f}[/bold] " + f"[{delta_color}]{arrow} {sign}{result.pass_rate_improvement:.4f}[/{delta_color}] " + f"[{delta_color}]({label})[/{delta_color}]") + table.add_row("pass_rate", rate_value) + table.add_row("rounds", f"{accepted} accepted / {result.total_rounds} total") + table.add_row("duration", f"{result.duration_seconds:.2f}s") + stop_text = _format_stop_reason_text(result.stop_reason) + if stop_text is not None: + table.add_row("stopped by", stop_text) + if result.status != "SUCCEEDED" and result.error_message: + table.add_row("error", f"[red]{result.error_message}[/red]") + update_msg = _AsciiReporter._format_update_source_line(result=result, + output_dir=output_dir, + update_source=update_source) + if update_msg: + table.add_row("update_source", update_msg.split(":", 1)[1].strip()) + table.add_row("artifacts", f"{output_dir}/") + for name, content in result.best_prompts.items(): + display = _truncate(name, _FIELD_NAME_DISPLAY_LIMIT) + table.add_row("", f"best_prompts/{display}.md [dim]({len(content)} chars)[/dim]") + table.add_row("", "result.json summary.txt rounds/ run.log") + + title_style = "bold green" if result.status == "SUCCEEDED" else "bold red" + panel = Panel( + table, + title=f"[{title_style}]Optimization complete · {result.status}[/{title_style}]", + box=box.ROUNDED, + padding=(0, 1), + ) + self._console.print("") + self._console.print(panel) + + metric_keys = _ordered_metric_keys( + result.baseline_metric_breakdown, + result.best_metric_breakdown, + extra=list(result.metric_thresholds.keys()), + ) + if metric_keys: + mt = Table( + title="per-metric scores", + box=box.SIMPLE_HEAVY, + show_header=True, + header_style="bold", + title_style="dim", + ) + mt.add_column("metric", no_wrap=True) + mt.add_column("threshold", justify="right") + mt.add_column("baseline", justify="right") + mt.add_column("best", justify="right") + mt.add_column("delta", justify="right") + mt.add_column("status", justify="right") + for name in metric_keys: + base = result.baseline_metric_breakdown.get(name) + best = result.best_metric_breakdown.get(name) + threshold = result.metric_thresholds.get(name) + delta = (best or 0.0) - (base or 0.0) + d_color = ("green" if delta > 0 else "red" if delta < 0 else "dim") + d_arrow, d_text = _format_delta(delta, ascii_only=False) + base_str = _format_score(base) + best_str = _format_score(best) + threshold_str = (f"{threshold:.4f}" if threshold is not None else "-") + status = _baseline_metric_status(best, threshold, ascii_only=False) + status_color = ("green" if status == "PASS" else "red" if status == "FAIL" else "dim") + mt.add_row( + name, + threshold_str, + base_str, + best_str, + f"[{d_color}]{d_arrow} {d_text}[/{d_color}]", + f"[{status_color}]{status}[/{status_color}]", + ) + self._console.print(mt) + + def run_failed( + self, + *, + baseline_prompts: dict[str, str], + output_dir: str, + error_message: str, + ) -> None: + from rich.panel import Panel + from rich import box + + self._stop_progress() + + body = (f"[red]error :[/red] {error_message}\n" + f"output_dir : {output_dir}\n" + f"baseline preserved at {os.path.join(output_dir, 'baseline_prompts')}") + panel = Panel( + body, + title="[bold red]Optimization FAILED[/bold red]", + box=box.ROUNDED, + padding=(0, 1), + ) + self._console.print("") + self._console.print(panel) + + +class _SilentGepaLogger: + """gepa-LoggerProtocol-compatible sink used to suppress library logs. + + With ``verbose<=1`` every message is dropped; with ``verbose>=2`` messages + are forwarded to the ``trpc_agent_sdk.optimizer.gepa`` logger at INFO + level so callers can route them via the standard logging configuration. + """ + + def __init__(self, *, verbose: int) -> None: + self._verbose = verbose + self._target = logging.getLogger(_GEPA_LOGGER_NAME) if verbose >= 2 else None + + def log(self, message: str) -> None: + if self._target is not None: + self._target.info("%s", message) + + +def create_reporter( + *, + verbose: int = 1, + stream: TextIO = sys.stdout, +) -> OptimizeReporter: + """Pick the appropriate reporter backend. + + Resolution order: ``verbose == 0`` returns :class:`_NullReporter`; + otherwise the factory attempts to import ``rich`` and returns + :class:`_RichReporter` on success or :class:`_AsciiReporter` on failure. + Unknown ``verbose`` values are normalised to ``1``. + """ + if verbose == 0: + return _NullReporter() + if verbose not in (1, 2): + verbose = 1 + try: + import rich # noqa: F401 + except ImportError: + return _AsciiReporter(stream=stream, verbose=verbose) + return _RichReporter(stream=stream, verbose=verbose) diff --git a/trpc_agent_sdk/evaluation/_optimize_result.py b/trpc_agent_sdk/evaluation/_optimize_result.py new file mode 100644 index 000000000..28e7928d7 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_optimize_result.py @@ -0,0 +1,361 @@ +# 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. +"""Optimization result data structures.""" + +from __future__ import annotations + +import os +from typing import Any +from typing import Literal +from typing import Optional + +from pydantic import Field + +from ._common import EvalBaseModel + +RunStatus = Literal["SUCCEEDED", "FAILED", "CANCELED"] + +FinishReason = Literal[ + "completed", + "perfect_pass_rate", + "no_improvement", + "error", +] + +StopReason = Literal[ + "required_metrics_passing", + "budget_exhausted", + "no_improvement", + "timeout", + "score_threshold", + "max_candidate_proposals", + "max_tracked_candidates", + "user_requested_stop", + "completed", +] + +RoundKind = Literal["reflective", "merge"] + + +class RoundRecord(EvalBaseModel): + """Per-round optimization record. + + Attributes: + round: 1-based round index. + optimized_field_names: Field names actually rewritten by the optimize model this round. + candidate_prompts: Full candidate map for the round; reused fields carry the previous text. + train_pass_rate: Currently always 0.0; see field description below. + validation_pass_rate: Pass rate on the validation split. + metric_breakdown: Mean score per metric on the validation split. + accepted: True iff the candidate was accepted as new best. + acceptance_reason: Human-readable reason for the acceptance decision. + failed_case_ids: Eval case ids that failed the validation split this round. + failed_cases_truncated: Number of failed cases dropped by token-budget truncation. + per_field_diagnosis: Diagnosis text from the reflection LM, keyed by optimized field name. + reflection_lm_calls: Number of reflection LM invocations this round (including retries). + round_llm_cost: USD cost for this round (reflection LM + evaluator). + round_token_usage: Token usage for this round; keys are "prompt", "completion", "total". + started_at: ISO-8601 timestamp when the round started. + duration_seconds: Wall-clock duration of the round in seconds. + extras: Free-form business payload; the optimizer never reads or modifies it. + """ + + round: int = Field(description="1-based round index.") + optimized_field_names: list[str] = Field(description="Field names rewritten by the optimize model this round.", ) + candidate_prompts: dict[str, str] = Field(description="Full candidate prompt map for the round.", ) + + train_pass_rate: float = Field( + default=0.0, + description=("Currently always 0.0: gepa does not expose a full-train-set pass " + "rate (it only samples minibatches each round). Use " + "train_subsample_parent_score / train_subsample_candidate_score " + "for per-round minibatch metrics instead."), + ) + validation_pass_rate: float = Field(description="Pass rate on the validation split.") + metric_breakdown: dict[str, float] = Field( + default_factory=dict, + description=("Mean score per metric on the validation split. Empty when the " + "round was skipped before valset evaluation, or when the " + "evaluator did not expose per-metric scores."), + ) + + accepted: bool = Field(description="True iff the candidate was accepted as new best.") + acceptance_reason: str = Field(default="", description="Human-readable acceptance reason.") + + failed_case_ids: list[str] = Field( + default_factory=list, + description="Eval case ids that failed validation this round.", + ) + failed_cases_truncated: int = Field( + default=0, + description="Number of failed cases dropped by token-budget truncation.", + ) + per_field_diagnosis: dict[str, str] = Field( + default_factory=dict, + description="Diagnosis text from the reflection LM, keyed by optimized field name.", + ) + reflection_lm_calls: int = Field( + default=0, + description="Number of reflection LM invocations this round (including retries).", + ) + + round_llm_cost: float = Field( + default=0.0, + description="USD cost for this round (reflection LM + evaluator).", + ) + round_token_usage: dict[str, int] = Field( + default_factory=lambda: { + "prompt": 0, + "completion": 0, + "total": 0 + }, + description='Token usage for this round; keys are "prompt", "completion", "total".', + ) + + started_at: str = Field(description="ISO-8601 timestamp when the round started.") + duration_seconds: float = Field(description="Wall-clock duration of the round in seconds.") + + kind: RoundKind = Field( + default="reflective", + description=("Mutation kind for this round: 'reflective' for the standard " + "reflective proposal step and 'merge' for system-aware merges."), + ) + train_minibatch_size: int = Field( + default=0, + description=("Cases sampled from the training set this round. 0 when the round " + "skipped before sampling (e.g. 'no proposal')."), + ) + train_subsample_parent_score: Optional[float] = Field( + default=None, + description=("Parent candidate's score on the sampled minibatch; None when no " + "subsample was produced."), + ) + train_subsample_candidate_score: Optional[float] = Field( + default=None, + description=("New candidate's score on the sampled minibatch; None when no " + "candidate was evaluated."), + ) + skip_reason: Optional[str] = Field( + default=None, + description=("Human-readable reason set on skipped rounds (e.g. " + "'subsample perfect', 'no proposal'). None when the round ran " + "normally or ended in an error."), + ) + error_message: Optional[str] = Field( + default=None, + description="Error message when the round ended in an algorithm error.", + ) + budget_used: Optional[int] = Field( + default=None, + description=("Cumulative metric calls consumed across all rounds so far. None " + "when the algorithm does not track a budget."), + ) + budget_total: Optional[int] = Field( + default=None, + description="Configured budget cap (e.g. max_metric_calls); None means 'auto'.", + ) + + extras: dict[str, Any] = Field( + default_factory=dict, + description="Free-form business payload; optimizer ignores it.", + ) + + +class OptimizeResult(EvalBaseModel): + """Top-level optimization result. + + Attributes: + schema_version: Result schema version; bumped on breaking layout changes. + algorithm: Algorithm name that produced this result. + status: Final run status. + finish_reason: Why the loop stopped. + error_message: Error message when status is FAILED. + baseline_pass_rate: Validation pass rate of the baseline prompts. + best_pass_rate: Validation pass rate of the best prompts. + pass_rate_improvement: best_pass_rate minus baseline_pass_rate. + baseline_metric_breakdown: Mean score per metric for the baseline. + best_metric_breakdown: Mean score per metric for the best prompts. + baseline_prompts: Initial prompt text keyed by TargetPrompt name. + best_prompts: Best prompt text keyed by TargetPrompt name. + total_rounds: Number of rounds executed. + rounds: Per-round records in order. + total_reflection_lm_calls: Total reflection LM invocations (including retries). + total_judge_model_calls: Currently always 0; see field description below. + total_llm_cost: USD cost across the whole run (reflection LM + evaluator). + total_token_usage: Token usage across the whole run; keys are "prompt", "completion", "total". + duration_seconds: Wall-clock duration of the whole run in seconds. + started_at: ISO-8601 timestamp when the run started. + finished_at: ISO-8601 timestamp when the run finished. + extras: Free-form business payload; the optimizer never reads or modifies it. + """ + + schema_version: str = Field(default="v1", description="Result schema version.") + algorithm: str = Field(description=("Algorithm name that produced this result; matches the registered key in " + "OPTIMIZER_REGISTRY (e.g. 'gepa_reflective')."), ) + + status: RunStatus = Field(description="Final run status.") + finish_reason: FinishReason = Field(description="Why the loop stopped.") + stop_reason: Optional[StopReason] = Field( + default=None, + description=("Which stop policy ended the run: 'required_metrics_passing' when " + "the framework's per-metric threshold policy fired; " + "'budget_exhausted' on MaxMetricCallsStopper; 'no_improvement' on " + "NoImprovementStopper; 'timeout' on TimeoutStopCondition; " + "'score_threshold' on ScoreThresholdStopper; " + "'max_candidate_proposals' / 'max_tracked_candidates' on the " + "respective candidate caps; 'completed' when the GEPA loop ended " + "without any registered stopper firing. None on FAILED runs that " + "errored before any stopper ran."), + ) + error_message: str = Field(default="", description="Error message when status is FAILED.") + + baseline_pass_rate: float = Field(description="Baseline validation pass rate.") + best_pass_rate: float = Field(description="Best validation pass rate.") + pass_rate_improvement: float = Field(description="best_pass_rate minus baseline_pass_rate.") + + baseline_metric_breakdown: dict[str, float] = Field( + default_factory=dict, + description="Mean score per metric for the baseline.", + ) + best_metric_breakdown: dict[str, float] = Field( + default_factory=dict, + description="Mean score per metric for the best prompts.", + ) + metric_thresholds: dict[str, float] = Field( + default_factory=dict, + description=("PASS/FAIL threshold per metric, copied from evaluate.metrics[].threshold. " + "Lets reporters and summary.txt show baseline / best scores alongside " + "the per-metric threshold so users can see at a glance whether a metric " + "is now above or below its acceptance bar."), + ) + + per_metric_best_candidates: dict[str, list[int]] = Field( + default_factory=dict, + description=("Per-metric Pareto-best candidate indices reported by GEPA. Keyed by " + "metric name; the list contains 0-based indices into the candidate " + "trajectory. Empty when the underlying algorithm does not expose " + "per-objective fronts. Useful for diagnosing which candidate excels " + "on which metric independent of the aggregated best."), + ) + + baseline_prompts: dict[str, str] = Field( + default_factory=dict, + description="Initial prompt text keyed by TargetPrompt name.", + ) + best_prompts: dict[str, str] = Field( + default_factory=dict, + description="Best prompt text keyed by TargetPrompt name.", + ) + + total_rounds: int = Field(description="Number of rounds executed.") + rounds: list[RoundRecord] = Field( + default_factory=list, + description="Per-round records in order.", + ) + + total_reflection_lm_calls: int = Field(description="Total reflection LM invocations (including retries).", ) + total_judge_model_calls: int = Field( + default=0, + description=("Currently always 0: the evaluator does not surface per-judge " + "invocation counts. Reflection LM cost is reflected in " + "total_reflection_lm_calls / total_llm_cost; for judge cost use " + "your LLM provider's billing dashboard."), + ) + total_llm_cost: float = Field( + default=0.0, + description="USD cost across the whole run.", + ) + total_token_usage: dict[str, int] = Field( + default_factory=lambda: { + "prompt": 0, + "completion": 0, + "total": 0 + }, + description='Token usage across the whole run; keys are "prompt", "completion", "total".', + ) + + duration_seconds: float = Field(description="Wall-clock duration of the run in seconds.") + started_at: str = Field(description="ISO-8601 timestamp when the run started.") + finished_at: str = Field(description="ISO-8601 timestamp when the run finished.") + + extras: dict[str, Any] = Field( + default_factory=dict, + description="Free-form business payload; optimizer ignores it.", + ) + + def dump_to(self, path: str) -> None: + """Serialize the result to a JSON file using model_dump_json(indent=2).""" + payload = self.model_dump_json(indent=2, by_alias=True) + with open(path, "w", encoding="utf-8") as fp: + fp.write(payload) + + @classmethod + def from_file(cls, path: str) -> "OptimizeResult": + """Load an OptimizeResult previously written by dump_to.""" + with open(path, "r", encoding="utf-8") as fp: + payload = fp.read() + return cls.model_validate_json(payload) + + def format_summary(self, *, output_dir: str, update_source: bool) -> str: + """Render the human-readable text summary persisted as ``summary.txt``. + + The layout mirrors the terminal summary so users can copy paste any + line directly. Algorithm name, status, baseline / best pass rates, + delta, rounds, duration, error message (when present), best prompt + inventory and the output directory are always included. + """ + sign = "+" if self.pass_rate_improvement >= 0 else "" + if self.pass_rate_improvement > 0: + label = "improved" + elif self.pass_rate_improvement < 0: + label = "regressed" + else: + label = "no improvement" + accepted = sum(1 for r in self.rounds if r.accepted) + lines: list[str] = [ + f"Optimization complete | status={self.status} | algorithm={self.algorithm}", + "", + f"pass_rate : {self.baseline_pass_rate:.4f} -> {self.best_pass_rate:.4f}" + f" ({sign}{self.pass_rate_improvement:.4f}, {label})", + f"rounds : {accepted} accepted / {self.total_rounds} total", + f"duration : {self.duration_seconds:.2f}s", + f"started_at : {self.started_at}", + f"finished_at : {self.finished_at}", + ] + if self.status != "SUCCEEDED" and self.error_message: + lines.append(f"error_message : {self.error_message}") + if self.stop_reason is not None: + lines.append(f"stop_reason : {self.stop_reason}") + lines.append(f"update_source : {'true' if update_source else 'false'}") + lines.append(f"output_dir : {output_dir}") + if (self.baseline_metric_breakdown or self.best_metric_breakdown or self.metric_thresholds): + lines.append("") + lines.append("metric breakdown (threshold | baseline -> best):") + keys = sorted({ + *self.baseline_metric_breakdown.keys(), + *self.best_metric_breakdown.keys(), + *self.metric_thresholds.keys(), + }) + for name in keys: + b = self.baseline_metric_breakdown.get(name, float("nan")) + t = self.best_metric_breakdown.get(name, float("nan")) + if name in self.metric_thresholds: + threshold_str = f"{self.metric_thresholds[name]:.4f}" + else: + threshold_str = " - " + lines.append(f" - {name:<40s} threshold {threshold_str} " + f"{b:.4f} -> {t:.4f}") + if self.best_prompts: + lines.append("") + lines.append("best prompts:") + for name, content in self.best_prompts.items(): + rel = os.path.join("best_prompts", f"{name}.md") + lines.append(f" - {name:<40s} {len(content)} chars ({rel})") + lines.append("") + lines.append(f"artifacts directory: {output_dir}") + lines.append(" result.json summary.txt rounds/ run.log " + "baseline_prompts/ best_prompts/ config.snapshot.json") + return "\n".join(lines) + "\n" diff --git a/trpc_agent_sdk/evaluation/_remote_eval_service.py b/trpc_agent_sdk/evaluation/_remote_eval_service.py new file mode 100644 index 000000000..edde246da --- /dev/null +++ b/trpc_agent_sdk/evaluation/_remote_eval_service.py @@ -0,0 +1,475 @@ +# 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. +"""Remote (black-box) eval service driven by async call_agent(query)->str.""" + +from __future__ import annotations + +import asyncio +import inspect +import time +import uuid +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.log import error as log_error +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from ._eval_callbacks import Callbacks +from ._eval_callbacks import CallbacksRunner +from ._eval_callbacks import EvalSetRunResult +from ._eval_case import EvalCase +from ._eval_case import EvalModeTrace +from ._eval_case import Invocation +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_result import EvalCaseResult +from ._eval_result import EvalMetricResult +from ._eval_result import EvalMetricResultDetails +from ._eval_result import EvalMetricResultPerInvocation +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._eval_service_base import BaseEvalService +from ._eval_service_base import EvaluateConfig +from ._eval_service_base import EvaluateRequest +from ._eval_service_base import InferenceRequest +from ._eval_service_base import InferenceResult +from ._eval_service_base import InferenceStatus +from ._eval_set_results_manager_base import EvalSetResultsManager +from ._eval_sets_manager_base import EvalSetsManager +from ._evaluator_registry import EVALUATOR_REGISTRY +from ._evaluator_registry import EvaluatorRegistry + +CallAgent = Callable[[str], Awaitable[str]] +# Metrics that cannot run under RemoteEvalService (black-box / call_agent +# mode) because they need information this service does not capture: +# - ``tool_trajectory_avg_score`` needs per-step tool call traces. +# - ``llm_rubric_knowledge_recall`` reads tool responses from +# ``Invocation.intermediate_data``; this service always emits +# ``intermediate_data=None`` (see ``_perform_inference_single_eval_item``), +# so the judge would silently see "No knowledge search results were +# found." for every case. +REMOTE_EVAL_INCOMPATIBLE_METRICS: frozenset[str] = frozenset({ + "tool_trajectory_avg_score", + "llm_rubric_knowledge_recall", +}) +EVAL_SESSION_ID_PREFIX = "___remote_eval___session___" + + +def _get_session_id() -> str: + return f"{EVAL_SESSION_ID_PREFIX}{str(uuid.uuid4())}" + + +class RemoteEvalService(BaseEvalService): + """Eval service for remote/black-box agents via call_agent.""" + + def __init__( + self, + call_agent: CallAgent, + eval_sets_manager: EvalSetsManager, + evaluator_registry: Optional[EvaluatorRegistry] = None, + eval_set_results_manager: Optional[EvalSetResultsManager] = None, + session_id_supplier: Callable[[], str] = _get_session_id, + callbacks: Optional[Callbacks] = None, + ): + self._validate_call_agent_is_async(call_agent) + self._call_agent = call_agent + self._eval_sets_manager = eval_sets_manager + self._evaluator_registry = evaluator_registry or EVALUATOR_REGISTRY + self._eval_set_results_manager = eval_set_results_manager + self._session_id_supplier = session_id_supplier + self._callbacks_runner = CallbacksRunner(callbacks or Callbacks()) + + @staticmethod + def _validate_call_agent_is_async(call_agent: Any) -> None: + if not callable(call_agent): + raise ValueError("call_agent must be callable.") + if not inspect.iscoroutinefunction(call_agent): + raise ValueError("call_agent must be an async function: async def call_agent(query: str) -> str") + + @staticmethod + def _user_content_to_str(content: Content) -> str: + parts = getattr(content, "parts", []) or [] + chunks: list[str] = [] + for part in parts: + text = getattr(part, "text", None) + if isinstance(text, str): + chunks.append(text) + return "".join(chunks) + + @staticmethod + def _reject_trace_cases(eval_cases: list[EvalCase]) -> None: + trace_ids = [case.eval_id for case in eval_cases if case.eval_mode == EvalModeTrace] + if trace_ids: + raise ValueError(f"call_agent mode is incompatible with trace cases: {trace_ids}") + + @override + async def perform_inference( + self, + inference_request: InferenceRequest, + ) -> AsyncGenerator[InferenceResult, None]: + eval_set = self._eval_sets_manager.get_eval_set( + app_name=inference_request.app_name, + eval_set_id=inference_request.eval_set_id, + ) + if not eval_set: + raise ValueError(f"Eval set with id {inference_request.eval_set_id} not found for app " + f"{inference_request.app_name}") + + eval_cases = eval_set.eval_cases + if inference_request.eval_case_ids: + eval_cases = [c for c in eval_cases if c.eval_id in inference_request.eval_case_ids] + self._reject_trace_cases(eval_cases) + + run_ctx: dict[str, Any] = {} + start_time = time.monotonic() + inference_results_list: list[InferenceResult] = [] + set_error: Optional[Exception] = None + await self._callbacks_runner.run_before_inference_set(inference_request, run_ctx) + semaphore = asyncio.Semaphore(value=inference_request.inference_config.parallelism) + + async def run_one(eval_case: EvalCase) -> InferenceResult: + case_ctx = run_ctx.copy() + session_id = self._session_id_supplier() + await self._callbacks_runner.run_before_inference_case( + inference_request, + eval_case.eval_id, + session_id, + case_ctx, + ) + case_start = time.monotonic() + async with semaphore: + result = await self._perform_inference_single_eval_item( + app_name=inference_request.app_name, + eval_set_id=inference_request.eval_set_id, + eval_case=eval_case, + session_id=session_id, + ) + await self._callbacks_runner.run_after_inference_case( + inference_request, + result, + None, + case_start, + case_ctx, + ) + return result + + try: + tasks = [run_one(eval_case) for eval_case in eval_cases] + for coro in asyncio.as_completed(tasks): + inference_result = await coro + inference_results_list.append(inference_result) + yield inference_result + except Exception as e: + set_error = e + raise + finally: + await self._callbacks_runner.run_after_inference_set( + inference_request, + inference_results_list, + set_error, + start_time, + run_ctx, + ) + + async def _perform_inference_single_eval_item( + self, + app_name: str, + eval_set_id: str, + eval_case: EvalCase, + session_id: Optional[str] = None, + ) -> InferenceResult: + if session_id is None: + session_id = self._session_id_supplier() + inference_result = InferenceResult( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_id=eval_case.eval_id, + session_id=session_id, + ) + try: + if not eval_case.conversation: + raise ValueError(f"inference eval case (eval_case_id={eval_case.eval_id}, session_id={session_id}): " + "conversation is required in call_agent mode") + inferences: list[Invocation] = [] + for source_invocation in eval_case.conversation: + query = self._user_content_to_str(source_invocation.user_content) + response_text = await self._call_agent(query) + inferences.append( + Invocation( + invocation_id=source_invocation.invocation_id, + user_content=source_invocation.user_content, + final_response=Content(parts=[Part(text=response_text)]), + intermediate_data=None, + creation_timestamp=time.time(), + )) + inference_result.inferences = inferences + inference_result.status = InferenceStatus.SUCCESS + return inference_result + except Exception as ex: # pylint: disable=broad-except + log_error( + "Inference failed for eval case `%s` with error %s.", + eval_case.eval_id, + ex, + exc_info=True, + ) + inference_result.status = InferenceStatus.FAILURE + inference_result.error_message = str(ex) + return inference_result + + def _validate_remote_metric_compat(self, evaluate_config: EvaluateConfig) -> None: + incompatible = sorted({ + metric.metric_name + for metric in evaluate_config.eval_metrics if metric.metric_name in REMOTE_EVAL_INCOMPATIBLE_METRICS + }) + if incompatible: + raise ValueError("call_agent mode does not support metrics: " + f"{incompatible}. Please remove them from EvalConfig.") + + @override + async def evaluate( + self, + evaluate_request: EvaluateRequest, + ) -> AsyncGenerator[EvalCaseResult, None]: + self._validate_remote_metric_compat(evaluate_request.evaluate_config) + run_ctx: dict[str, Any] = {} + start_time = time.monotonic() + eval_case_results_list: list[EvalCaseResult] = [] + set_error: Optional[Exception] = None + ir0 = evaluate_request.inference_results[0] if evaluate_request.inference_results else None + app_name = ir0.app_name if ir0 else "" + eval_set_id = ir0.eval_set_id if ir0 else "" + await self._callbacks_runner.run_before_evaluate_set(evaluate_request, run_ctx) + semaphore = asyncio.Semaphore(value=evaluate_request.evaluate_config.parallelism) + + async def run_one_eval(inference_result: InferenceResult) -> tuple[InferenceResult, EvalCaseResult]: + case_ctx = run_ctx.copy() + await self._callbacks_runner.run_before_evaluate_case( + evaluate_request, + inference_result.eval_case_id, + case_ctx, + ) + case_start = time.monotonic() + async with semaphore: + inference_result, eval_case_result = await self._evaluate_single_inference_result( + inference_result=inference_result, + evaluate_config=evaluate_request.evaluate_config, + ) + await self._callbacks_runner.run_after_evaluate_case( + evaluate_request, + inference_result, + eval_case_result, + None, + case_start, + case_ctx, + ) + return (inference_result, eval_case_result) + + try: + tasks = [run_one_eval(ir) for ir in evaluate_request.inference_results] + for coro in asyncio.as_completed(tasks): + _, eval_case_result = await coro + eval_case_results_list.append(eval_case_result) + yield eval_case_result + if self._eval_set_results_manager and eval_case_results_list and app_name: + sorted_results = sorted(eval_case_results_list, key=lambda r: (r.run_id or 0, r.eval_id)) + self._eval_set_results_manager.save_eval_set_result( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_results=sorted_results, + ) + except Exception as e: + set_error = e + raise + finally: + await self._callbacks_runner.run_after_evaluate_set( + evaluate_request, + EvalSetRunResult( + app_name=app_name, + eval_set_id=eval_set_id, + eval_case_results=eval_case_results_list, + ), + set_error, + start_time, + run_ctx, + ) + + async def _evaluate_single_inference_result( + self, + inference_result: InferenceResult, + evaluate_config: EvaluateConfig, + ) -> tuple[InferenceResult, EvalCaseResult]: + eval_case = self._eval_sets_manager.get_eval_case( + app_name=inference_result.app_name, + eval_set_id=inference_result.eval_set_id, + eval_case_id=inference_result.eval_case_id, + ) + if eval_case is None: + raise ValueError(f"Eval case with id {inference_result.eval_case_id} not found for " + f"app {inference_result.app_name} and eval set {inference_result.eval_set_id}.") + + expected_invocations = self._build_expected_invocations_for_eval(eval_case) + eval_metric_result_per_invocation: list[EvalMetricResultPerInvocation] = [] + overall_eval_metric_results: list[EvalMetricResult] = [] + + if inference_result.inferences: + for idx, actual in enumerate(inference_result.inferences): + expected = None + if expected_invocations and idx < len(expected_invocations): + expected = expected_invocations[idx] + eval_metric_result_per_invocation.append( + EvalMetricResultPerInvocation( + actual_invocation=actual, + expected_invocation=expected, + eval_metric_results=[], + )) + + case_error_message: Optional[str] = inference_result.error_message + if inference_result.status == InferenceStatus.FAILURE: + for eval_metric in evaluate_config.eval_metrics: + overall_eval_metric_results.append( + EvalMetricResult( + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + score=None, + eval_status=EvalStatus.NOT_EVALUATED, + )) + for invocation in eval_metric_result_per_invocation: + invocation.eval_metric_results.append( + EvalMetricResult( + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + score=None, + eval_status=EvalStatus.NOT_EVALUATED, + )) + return ( + inference_result, + EvalCaseResult( + eval_set_id=inference_result.eval_set_id, + eval_id=inference_result.eval_case_id, + run_id=getattr(inference_result, "run_id", None), + final_eval_status=EvalStatus.NOT_EVALUATED, + error_message=case_error_message, + overall_eval_metric_results=overall_eval_metric_results, + eval_metric_result_per_invocation=eval_metric_result_per_invocation, + session_id=inference_result.session_id or "", + session_details=None, + user_id=None, + ), + ) + + for eval_metric in evaluate_config.eval_metrics: + try: + evaluation_result = await self._evaluate_metric( + eval_metric=eval_metric, + actual_invocations=inference_result.inferences or [], + expected_invocations=expected_invocations, + ) + except Exception as e: # pylint: disable=broad-except + if case_error_message is None: + case_error_message = str(e) + log_error( + "Metric evaluation failed for metric `%s` for eval case id '%s' with following error `%s`", + eval_metric.metric_name, + inference_result.eval_case_id, + e, + exc_info=True, + ) + evaluation_result = EvaluationResult(overall_eval_status=EvalStatus.NOT_EVALUATED) + + reasons = [pr.reason for pr in evaluation_result.per_invocation_results if pr.reason is not None] + rubric_scores: list[Any] = [] + for pr in evaluation_result.per_invocation_results: + if pr.rubric_scores: + rubric_scores.extend(pr.rubric_scores) + overall_reason = ";".join(reasons) if reasons else None + overall_rubric = rubric_scores if rubric_scores else None + overall_eval_metric_results.append( + EvalMetricResult( + score=evaluation_result.overall_score, + eval_status=evaluation_result.overall_eval_status, + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + details=EvalMetricResultDetails( + reason=overall_reason, + score=evaluation_result.overall_score, + rubric_scores=overall_rubric, + ) if (overall_reason is not None or overall_rubric is not None) else None, + )) + + for idx, invocation in enumerate(eval_metric_result_per_invocation): + if idx < len(evaluation_result.per_invocation_results): + invocation_result = evaluation_result.per_invocation_results[idx] + else: + invocation_result = PerInvocationResult(actual_invocation=invocation.actual_invocation) + invocation.eval_metric_results.append( + EvalMetricResult( + score=invocation_result.score, + eval_status=invocation_result.eval_status, + metric_name=eval_metric.metric_name, + threshold=eval_metric.threshold, + criterion=eval_metric.criterion, + details=EvalMetricResultDetails( + reason=invocation_result.reason, + score=invocation_result.score, + rubric_scores=invocation_result.rubric_scores, + ) if + (invocation_result.reason is not None or invocation_result.rubric_scores is not None) else None, + )) + + eval_case_result = EvalCaseResult( + eval_set_id=inference_result.eval_set_id, + eval_id=inference_result.eval_case_id, + run_id=getattr(inference_result, "run_id", None), + final_eval_status=self._generate_final_eval_status(overall_eval_metric_results), + error_message=case_error_message, + overall_eval_metric_results=overall_eval_metric_results, + eval_metric_result_per_invocation=eval_metric_result_per_invocation, + session_id=inference_result.session_id or "", + session_details=None, + user_id=None, + ) + return (inference_result, eval_case_result) + + async def _evaluate_metric( + self, + eval_metric: EvalMetric, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + evaluator = self._evaluator_registry.get_evaluator(eval_metric) + if inspect.iscoroutinefunction(evaluator.evaluate_invocations): + return await evaluator.evaluate_invocations( + actual_invocations=actual_invocations, + expected_invocations=expected_invocations, + ) + return evaluator.evaluate_invocations( + actual_invocations=actual_invocations, + expected_invocations=expected_invocations, + ) + + @staticmethod + def _build_expected_invocations_for_eval(eval_case: EvalCase) -> Optional[list[Invocation]]: + if eval_case.conversation: + return list(eval_case.conversation) + return None + + @staticmethod + def _generate_final_eval_status(overall_eval_metric_results: list[EvalMetricResult]) -> EvalStatus: + final_eval_status = EvalStatus.NOT_EVALUATED + for result in overall_eval_metric_results: + if result.eval_status == EvalStatus.PASSED: + final_eval_status = EvalStatus.PASSED + elif result.eval_status == EvalStatus.FAILED: + return EvalStatus.FAILED + return final_eval_status diff --git a/trpc_agent_sdk/evaluation/_rouge_evaluator.py b/trpc_agent_sdk/evaluation/_rouge_evaluator.py new file mode 100644 index 000000000..48c720f3f --- /dev/null +++ b/trpc_agent_sdk/evaluation/_rouge_evaluator.py @@ -0,0 +1,173 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Rouge score evaluator for response matching.""" + +from __future__ import annotations + +import statistics +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.types import Content + +from ._eval_case import Invocation +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_metrics import Interval +from ._eval_metrics import MetricInfo +from ._eval_metrics import MetricValueInfo +from ._eval_metrics import PrebuiltMetrics +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._evaluator_base import Evaluator + + +class RougeEvaluator(Evaluator): + """Evaluates response matching using Rouge-1 metric. + + Rouge-1 measures the overlap of unigrams (single words) between + the actual and expected responses. + + Score range: [0, 1], where 1 means perfect match. + """ + + requires_reference = True + + def __init__( + self, + threshold: Optional[float] = None, + eval_metric: Optional[EvalMetric] = None, + ): + """Initialize the evaluator. + + Args: + threshold: Threshold value (deprecated, use eval_metric instead) + eval_metric: The metric configuration + + Raises: + ValueError: If both threshold and eval_metric are specified + """ + if threshold is not None and eval_metric: + raise ValueError("Either eval_metric should be specified or threshold should be " + "specified.") + + if eval_metric: + threshold = eval_metric.threshold + + self._threshold = threshold + + # Try to import rouge_scorer + try: + from rouge_score import rouge_scorer + self._rouge_scorer = rouge_scorer.RougeScorer(["rouge1"], use_stemmer=True) + except ImportError: + raise ImportError("rouge-score library is required for RougeEvaluator. " + "Install it with: pip install rouge-score") + + @staticmethod + def get_metric_info() -> MetricInfo: + """Get metadata information about this metric. + + Returns: + MetricInfo object describing this metric + """ + return MetricInfo( + metric_name=PrebuiltMetrics.RESPONSE_MATCH_SCORE.value, + description=("This metric compares the final response content between actual " + "and expected invocations using ROUGE-1 F1 score. ROUGE-1 measures " + "the overlap of unigrams (single words) between the two texts. " + "A score of 1.0 indicates perfect match, while 0.0 indicates no " + "overlap. Higher values are better."), + metric_value_info=MetricValueInfo(interval=Interval(min_value=0.0, max_value=1.0)), + ) + + @override + def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + """Evaluate response matching. + + Args: + actual_invocations: Invocations from the agent + expected_invocations: Expected invocations with reference responses + + Returns: + Evaluation result with Rouge-1 scores + + Raises: + ValueError: If expected_invocations is not provided + """ + if expected_invocations is None: + raise ValueError("expected_invocations is required for Rouge evaluation") + + per_invocation_results = [] + + for actual, expected in zip(actual_invocations, expected_invocations): + # Extract text from responses + actual_text = self._get_text_from_content(actual.final_response) + expected_text = self._get_text_from_content(expected.final_response) + + # Calculate Rouge-1 score + rouge_scores = self._rouge_scorer.score(expected_text, actual_text) + score = rouge_scores["rouge1"].fmeasure + + per_invocation_results.append( + PerInvocationResult(actual_invocation=actual, + expected_invocation=expected, + score=score, + eval_status=self._get_eval_status(score))) + + # Calculate overall score + overall_score = statistics.mean([r.score for r in per_invocation_results]) + + return EvaluationResult( + overall_score=overall_score, + overall_eval_status=self._get_eval_status(overall_score), + per_invocation_results=per_invocation_results, + ) + + def _get_text_from_content(self, content: Optional[Content]) -> str: + """Extract text from Content object. + + Args: + content: The Content object + + Returns: + Concatenated text from all parts + """ + if content and content.parts: + return "\n".join([part.text for part in content.parts if part.text]) + return "" + + def _get_eval_status(self, score: float) -> EvalStatus: + """Get evaluation status based on score. + + Args: + score: The computed score + + Returns: + PASSED if score >= threshold, FAILED otherwise + """ + return EvalStatus.PASSED if score >= self._threshold else EvalStatus.FAILED diff --git a/trpc_agent_sdk/evaluation/_static_user_simulator.py b/trpc_agent_sdk/evaluation/_static_user_simulator.py new file mode 100644 index 000000000..34c5159a0 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_static_user_simulator.py @@ -0,0 +1,83 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Static user simulator implementation. + +""" + +from __future__ import annotations + +import logging +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.events import Event + +from ._eval_case import StaticConversation +from ._evaluator_base import Evaluator +from ._user_simulator_base import BaseUserSimulatorConfig +from ._user_simulator_base import NextUserMessage +from ._user_simulator_base import Status +from ._user_simulator_base import UserSimulator + +logger = logging.getLogger(__name__) + + +class StaticUserSimulator(UserSimulator): + """A UserSimulator that returns a static list of user messages.""" + + def __init__(self, *, static_conversation: StaticConversation): + super().__init__(BaseUserSimulatorConfig(), config_type=BaseUserSimulatorConfig) + self.static_conversation = static_conversation + self.invocation_idx = 0 + + @override + async def get_next_user_message( + self, + events: list[Event], + ) -> NextUserMessage: + """Returns the next user message to send to the agent from a static list. + + Args: + events: The unaltered conversation history between the user and the + agent(s) under evaluation. + + Returns: + A NextUserMessage object containing the next user message to send to the + agent, or a status indicating why no message was generated. + """ + # check if we have reached the end of the list of invocations + if self.invocation_idx >= len(self.static_conversation): + return NextUserMessage(status=Status.STOP_SIGNAL_DETECTED) + + # return the next message in the static list + next_user_content = self.static_conversation[self.invocation_idx].user_content + self.invocation_idx += 1 + return NextUserMessage( + status=Status.SUCCESS, + user_message=next_user_content, + ) + + @override + def get_simulation_evaluator(self, ) -> Optional[Evaluator]: + """The StaticUserSimulator does not require an evaluator.""" + return None diff --git a/trpc_agent_sdk/evaluation/_target_prompt.py b/trpc_agent_sdk/evaluation/_target_prompt.py new file mode 100644 index 000000000..da104f153 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_target_prompt.py @@ -0,0 +1,243 @@ +# 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. +"""Multi-field prompt registry with atomic write_all for AgentOptimizer.""" + +from __future__ import annotations + +import inspect +import os +from pathlib import Path +from typing import Awaitable +from typing import Callable +from typing import Optional + +AsyncRead = Callable[[], Awaitable[str]] +AsyncWrite = Callable[[str], Awaitable[None]] + + +class _RollbackError(RuntimeError): + """Aggregate error raised when one or more path-field rollbacks fail. + + Carries ``(field_name, error)`` pairs for every field whose rollback + raised. The original ``write_all`` failure is preserved as + ``__context__`` (via ``raise ... from primary_err``) so chained + tracebacks surface both the root cause and every rollback failure. + + Private (underscore-prefixed) — users only observe it through the + formatted message in tracebacks; never declared in the public API. + """ + + def __init__(self, failures: list[tuple[str, BaseException]]) -> None: + self.failures = failures + details = "; ".join(f"{name}: {type(err).__name__}: {err}" for name, err in failures) + super().__init__(f"TargetPrompt.write_all rollback failed for " + f"{len(failures)} field(s): {details}") + + +class _Source: + """Base for a single registered prompt source.""" + + +class _PathSource(_Source): + """File-backed prompt source: read/write a UTF-8 text file at a fixed path.""" + + __slots__ = ("path", ) + + def __init__(self, path: str) -> None: + self.path = path + + +class _CallbackSource(_Source): + """Callback-backed prompt source: caller-provided async read/write functions.""" + + __slots__ = ("read_fn", "write_fn") + + def __init__(self, read_fn: AsyncRead, write_fn: AsyncWrite) -> None: + self.read_fn = read_fn + self.write_fn = write_fn + + +class TargetPrompt: + """Registry of prompt fields to be optimized by AgentOptimizer. + + Each field is registered with a unique name and one of two source forms: + - add_path(name, path): file-backed source; framework reads/writes the file + - add_callback(name, read=, write=): caller-backed source with async functions + + Typical use: + target = ( + TargetPrompt() + .add_path("system_prompt", "my_pkg/system.md") + .add_callback("retriever", read=load_fn, write=save_fn) + ) + + read_all / write_all operate on every registered field. write_all is atomic + for path-backed fields (tmp file + os.replace, rollback on partial failure); + callback-backed atomicity is the caller's responsibility. + """ + + def __init__(self) -> None: + self._sources: dict[str, _Source] = {} + + def add_path(self, name: str, path: str) -> "TargetPrompt": + """Register a file-backed prompt field. name must be unique.""" + self._reject_duplicate(name) + self._sources[name] = _PathSource(path) + return self + + def add_callback( + self, + name: str, + *, + read: AsyncRead, + write: AsyncWrite, + ) -> "TargetPrompt": + """Register a callback-backed prompt field with async read / write functions.""" + self._reject_duplicate(name) + if not inspect.iscoroutinefunction(read): + raise TypeError(f"add_callback {name!r}: read must be an async function") + if not inspect.iscoroutinefunction(write): + raise TypeError(f"add_callback {name!r}: write must be an async function") + self._sources[name] = _CallbackSource(read, write) + return self + + def names(self) -> list[str]: + """Return registered field names in insertion order.""" + return list(self._sources.keys()) + + def describe_source(self, name: str) -> str: + """Human-readable source label for a field. + + Path-backed fields return the file path verbatim; callback-backed + fields return the literal ``""``. Raises KeyError if name + is unknown. Used by the optimizer reporter header. + """ + src = self._sources[name] + if isinstance(src, _PathSource): + return src.path + return "" + + async def read(self, name: str) -> str: + """Read the value of a single registered field. Raises KeyError if name is unknown.""" + src = self._sources[name] + return await self._read_one(src) + + async def read_all(self) -> dict[str, str]: + """Read every registered field. Propagates underlying errors (FileNotFoundError / callback exceptions).""" + out: dict[str, str] = {} + for name, src in self._sources.items(): + out[name] = await self._read_one(src) + return out + + async def write_all(self, prompts: dict[str, str]) -> None: + """Atomically write all registered fields. Keys must exactly match registered names. + + Atomicity contract: + - Path fields: write to {path}.tmp, then os.replace (single-file POSIX-atomic rename). + - On any path write failure: already-renamed paths are rolled back to pre-call content, + residual .tmp files are removed, and the original exception propagates. Rollback uses + the same tmp + os.replace primitive, so an interrupted rollback cannot leave a path + field half-written. + - If rollback of any field also fails, the original exception is preserved on + ``__context__`` and a single ``_RollbackError`` listing every per-field rollback + failure propagates. Rollback is best-effort: a failure on one field does not skip + the remaining fields. + - Callback fields: invoked sequentially after every path write succeeds. A callback + failure rolls back path fields to the pre-call snapshot before propagating; callback + fields themselves are not rolled back (caller-owned idempotency). + """ + if set(prompts.keys()) != set(self._sources.keys()): + raise ValueError(f"TargetPrompt.write_all: prompts keys mismatch; " + f"expected {sorted(self._sources.keys())}, got {sorted(prompts.keys())}") + + path_backups = self._snapshot_path_contents() + written_paths: list[str] = [] + try: + for name, src in self._sources.items(): + if isinstance(src, _PathSource): + self._atomic_write_path(src.path, prompts[name]) + written_paths.append(name) + for name, src in self._sources.items(): + if isinstance(src, _CallbackSource): + await src.write_fn(prompts[name]) + except BaseException as primary_err: + rollback_failures = self._rollback_paths(written_paths, path_backups) + self._cleanup_tmp_files() + if rollback_failures: + raise _RollbackError(rollback_failures) from primary_err + raise + + def _reject_duplicate(self, name: str) -> None: + if name in self._sources: + raise ValueError(f"TargetPrompt: name {name!r} already registered") + + def _snapshot_path_contents(self) -> dict[str, Optional[str]]: + """Capture pre-call content of every path-backed field (None if source did not exist).""" + snapshot: dict[str, Optional[str]] = {} + for name, src in self._sources.items(): + if isinstance(src, _PathSource): + try: + snapshot[name] = Path(src.path).read_text(encoding="utf-8") + except FileNotFoundError: + snapshot[name] = None + return snapshot + + def _rollback_paths( + self, + written: list[str], + backups: dict[str, Optional[str]], + ) -> list[tuple[str, BaseException]]: + """Best-effort atomic rollback of every successfully written path field. + + For each field in ``written`` whose source did not exist before + write_all (``backups[name] is None``) the file is unlinked; for + fields that had pre-call content the content is restored via + ``_atomic_write_path`` (tmp + os.replace), so an interrupted + rollback cannot leave a path field half-written. + + Failures are collected and returned rather than raised, so a + single field's failure does not skip subsequent fields. The + caller wraps the collected failures into ``_RollbackError``. + """ + failures: list[tuple[str, BaseException]] = [] + for name in written: + src = self._sources[name] + if not isinstance(src, _PathSource): + continue + backup = backups.get(name) + try: + if backup is None: + try: + os.unlink(src.path) + except FileNotFoundError: + pass + else: + self._atomic_write_path(src.path, backup) + except BaseException as err: + failures.append((name, err)) + return failures + + def _cleanup_tmp_files(self) -> None: + for src in self._sources.values(): + if isinstance(src, _PathSource): + tmp = src.path + ".tmp" + try: + os.unlink(tmp) + except FileNotFoundError: + pass + + @staticmethod + def _atomic_write_path(path: str, content: str) -> None: + tmp = path + ".tmp" + Path(tmp).write_text(content, encoding="utf-8") + os.replace(tmp, path) + + async def _read_one(self, src: _Source) -> str: + if isinstance(src, _PathSource): + return Path(src.path).read_text(encoding="utf-8") + if isinstance(src, _CallbackSource): + return await src.read_fn() + raise TypeError(f"unknown source type: {type(src).__name__}") diff --git a/trpc_agent_sdk/evaluation/_trajectory_evaluator.py b/trpc_agent_sdk/evaluation/_trajectory_evaluator.py new file mode 100644 index 000000000..82da3092d --- /dev/null +++ b/trpc_agent_sdk/evaluation/_trajectory_evaluator.py @@ -0,0 +1,148 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Trajectory Evaluator for Tool Call Verification. + +Evaluates agent tool call trajectories against expected sequences. +""" + +from __future__ import annotations + +import statistics +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.types import FunctionCall + +from ._criterion_registry import CRITERION_REGISTRY +from ._eval_case import Invocation +from ._eval_case import get_all_tool_calls +from ._eval_metrics import EvalMetric +from ._eval_metrics import EvalStatus +from ._eval_metrics import Interval +from ._eval_metrics import MetricInfo +from ._eval_metrics import MetricValueInfo +from ._eval_metrics import PrebuiltMetrics +from ._eval_result import EvaluationResult +from ._eval_result import PerInvocationResult +from ._evaluator_base import Evaluator + + +class TrajectoryEvaluator(Evaluator): + """Compares tool call sequences. + + With criterion: ToolTrajectoryCriterion (order, subset, per-tool strategy). + Without: strict count, order, name and arguments match. + """ + + requires_reference = True + + def __init__( + self, + threshold: Optional[float] = None, + eval_metric: Optional[EvalMetric] = None, + ): + if threshold is not None and eval_metric: + raise ValueError("Either eval_metric should be specified or threshold should be " + "specified.") + + if eval_metric: + threshold = eval_metric.threshold + + self._threshold = threshold + self._trajectory_criterion: Optional[Any] = None + if eval_metric and eval_metric.criterion: + self._trajectory_criterion = CRITERION_REGISTRY.build(eval_metric.criterion, + metric_key=eval_metric.metric_name) + + @staticmethod + def get_metric_info() -> MetricInfo: + return MetricInfo( + metric_name=PrebuiltMetrics.TOOL_TRAJECTORY_AVG_SCORE.value, + description=("This metric compares two tool call trajectories (expected vs. " + "actual) for the same user interaction. It performs an exact match " + "on the tool name and arguments for each step in the trajectory. " + "A score of 1.0 indicates a perfect match, while 0.0 indicates a " + "mismatch. Higher values are better."), + metric_value_info=MetricValueInfo(interval=Interval(min_value=0.0, max_value=1.0)), + ) + + @override + def evaluate_invocations( + self, + actual_invocations: list[Invocation], + expected_invocations: Optional[list[Invocation]], + ) -> EvaluationResult: + """Compare tool call sequences per invocation. + + Return 1.0/0.0 per turn, mean overall. + """ + if expected_invocations is None: + raise ValueError("expected_invocations is required for trajectory evaluation") + + per_invocation_results = [] + + for actual, expected in zip(actual_invocations, expected_invocations): + actual_tool_calls = get_all_tool_calls(actual.intermediate_data) + expected_tool_calls = get_all_tool_calls(expected.intermediate_data) + + if self._trajectory_criterion is not None: + is_equal = self._trajectory_criterion.matches(actual_tool_calls, expected_tool_calls) + else: + is_equal = self._are_tool_calls_equal(actual_tool_calls, expected_tool_calls) + score = 1.0 if is_equal else 0.0 + + per_invocation_results.append( + PerInvocationResult(actual_invocation=actual, + expected_invocation=expected, + score=score, + eval_status=self._get_eval_status(score))) + + if per_invocation_results: + overall_score = statistics.mean([r.score for r in per_invocation_results]) + + return EvaluationResult( + overall_score=overall_score, + overall_eval_status=self._get_eval_status(overall_score), + per_invocation_results=per_invocation_results, + ) + + return EvaluationResult() + + def _are_tool_calls_equal( + self, + actual_tool_calls: list[FunctionCall], + expected_tool_calls: list[FunctionCall], + ) -> bool: + """True if same length and each pair has same name and args.""" + if len(actual_tool_calls) != len(expected_tool_calls): + return False + + for actual, expected in zip(actual_tool_calls, expected_tool_calls): + if actual.name != expected.name or actual.args != expected.args: + return False + + return True + + def _get_eval_status(self, score: float) -> EvalStatus: + return EvalStatus.PASSED if score >= self._threshold else EvalStatus.FAILED diff --git a/trpc_agent_sdk/evaluation/_user_simulator_base.py b/trpc_agent_sdk/evaluation/_user_simulator_base.py new file mode 100644 index 000000000..401635b5a --- /dev/null +++ b/trpc_agent_sdk/evaluation/_user_simulator_base.py @@ -0,0 +1,119 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""User simulator base classes. + +""" + +from __future__ import annotations + +import enum +from abc import ABC +from typing import Optional + +from pydantic import ConfigDict +from pydantic import Field +from pydantic import ValidationError +from pydantic import alias_generators +from pydantic import model_validator + +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.types import Content + +from ._common import EvalBaseModel +from ._evaluator_base import Evaluator + + +class BaseUserSimulatorConfig(EvalBaseModel): + """Base class for configurations pertaining to user simulator.""" + + model_config = ConfigDict( + alias_generator=alias_generators.to_camel, + populate_by_name=True, + extra="allow", # Override extra to allow additional fields + arbitrary_types_allowed=True, + ) + + +class Status(enum.Enum): + """The resulting status of get_next_user_message().""" + + SUCCESS = "success" + TURN_LIMIT_REACHED = "turn_limit_reached" + STOP_SIGNAL_DETECTED = "stop_signal_detected" + NO_MESSAGE_GENERATED = "no_message_generated" + + +class NextUserMessage(EvalBaseModel): + status: Status = Field(description="""The resulting status of `get_next_user_message()`. + +The caller of `get_next_user_message()` should inspect this field to determine +if the user simulator was able to successfully generate a message or why it was +not able to do so.""") + + user_message: Optional[Content] = Field(description="""The next user message.""", default=None) + + @model_validator(mode="after") + def ensure_user_message_iff_success(self) -> NextUserMessage: + if (self.status == Status.SUCCESS) == (self.user_message is None): + raise ValueError("A user_message should be provided if and only if the status is" + " SUCCESS") + return self + + +class UserSimulator(ABC): + """A user simulator for the purposes of automating interaction with an Agent. + + Typically, you must create one user simulator instance per eval case. + """ + + def __init__( + self, + config: BaseUserSimulatorConfig, + config_type: type[BaseUserSimulatorConfig], + ): + # Unpack the config to a specific type needed by the class implementing this + # interface. + try: + self._config = config_type.model_validate(config.model_dump()) + except ValidationError as ex: + raise ValueError(f"Expect config of type `{config_type}`.") from ex + + async def get_next_user_message( + self, + events: list[Event], + ) -> NextUserMessage: + """Returns the next user message to send to the agent. + + Args: + events: The unaltered conversation history between the user and the + agent(s) under evaluation. + + Returns: + A NextUserMessage object containing the next user message to send to the + agent, or a status indicating why no message was generated. + """ + ... + + def get_simulation_evaluator(self, ) -> Optional[Evaluator]: + """Returns an instance of an Evaluator that evaluates if the user simulation was successful or not.""" + ... diff --git a/trpc_agent_sdk/evaluation/_user_simulator_provider.py b/trpc_agent_sdk/evaluation/_user_simulator_provider.py new file mode 100644 index 000000000..178973f9a --- /dev/null +++ b/trpc_agent_sdk/evaluation/_user_simulator_provider.py @@ -0,0 +1,71 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""User simulator provider. + +""" + +from __future__ import annotations + +from typing import Optional + +from ._static_user_simulator import StaticUserSimulator +from ._eval_case import EvalCase +from ._user_simulator_base import BaseUserSimulatorConfig +from ._user_simulator_base import UserSimulator + + +class UserSimulatorProvider: + """Provides a UserSimulator instance per EvalCase, mixing configuration data + from the EvalConfig with per-EvalCase conversation data.""" + + def __init__( + self, + user_simulator_config: Optional[BaseUserSimulatorConfig] = None, + ): + if user_simulator_config is None: + user_simulator_config = BaseUserSimulatorConfig() + elif not isinstance(user_simulator_config, BaseUserSimulatorConfig): + # assume that the user simulator will fully validate the config it gets. + raise ValueError(f"Expect config of type `{BaseUserSimulatorConfig}`.") + self._user_simulator_config = user_simulator_config + + def provide(self, eval_case: EvalCase) -> UserSimulator: + """Provide an appropriate user simulator based on the type of conversation data in the EvalCase + + Args: + eval_case: An EvalCase containing a `conversation` xor a + `conversation_scenario`. + + Returns: + A StaticUserSimulator or an LlmBackedUserSimulator based on the type of + the conversation data. + + Raises: + ValueError: If no conversation data or multiple types of conversation data + are provided. + """ + if eval_case.conversation is None: + raise ValueError("No conversation data provided in EvalCase. " + "Static conversation is required.") + + return StaticUserSimulator(static_conversation=eval_case.conversation) diff --git a/trpc_agent_sdk/evaluation/_utils.py b/trpc_agent_sdk/evaluation/_utils.py new file mode 100644 index 000000000..605bba336 --- /dev/null +++ b/trpc_agent_sdk/evaluation/_utils.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 Apache-2.0. +"""Collect evaluation results and print summary to terminal.""" + +from __future__ import annotations + +import json +import os +import shutil +import statistics +from typing import Any +from typing import Optional + +from trpc_agent_sdk.types import Content + +from ._eval_case import IntermediateDataType +from ._eval_case import Invocation +from ._eval_case import get_all_tool_calls +from ._eval_metrics import EvalStatus +from ._eval_result import EvalCaseResult +from ._eval_result import EvalMetricResult +from ._eval_set import EvalSet + +# Constants +DEFAULT_TERMINAL_WIDTH = 80 +MIN_SECTION_HEADER_WIDTH = 100 +VERTICAL_FIELD_INDENT = " " +VERTICAL_FIELD_MAX_LEN = 500 +VERTICAL_FIELD_PREFIX = " " +INVOCATION_FIELD_INDENT = " " +INVOCATION_FIELD_PREFIX = " " +RESULT_LABELS = ("Agent Name", "Eval Set", "Overall Status", "Runs") + + +def _result_label_width() -> int: + """Width for aligned result labels (Agent Name, Eval Set, etc.).""" + return max(len(label) for label in RESULT_LABELS) + + +class MetricRunRecord: + """One run's record for a metric: actual/expected invocation + metric result.""" + + __slots__ = ("actual_invocation", "expected_invocation", "eval_metric_result") + + def __init__( + self, + *, + actual_invocation: Invocation, + expected_invocation: Invocation, + eval_metric_result: EvalMetricResult, + ): + self.actual_invocation = actual_invocation + self.expected_invocation = expected_invocation + self.eval_metric_result = eval_metric_result + + +_EvalMetricResultWithInvocation = MetricRunRecord # alias + + +class EvalResultHandler: + """Handles evaluation result collection and terminal output.""" + + def eval_status_str(self, status: EvalStatus) -> str: + if status == EvalStatus.PASSED: + return "passed" + if status == EvalStatus.FAILED: + return "failed" + return "not_evaluated" + + def _terminal_width(self) -> int: + try: + cols = os.environ.get("COLUMNS") + if cols is not None: + w = int(cols) + if w > 0: + return w + except (ValueError, TypeError): + pass + try: + return shutil.get_terminal_size().columns or DEFAULT_TERMINAL_WIDTH + except Exception: + return DEFAULT_TERMINAL_WIDTH + + def print_section_header(self, title: str) -> None: + width = max(self._terminal_width(), MIN_SECTION_HEADER_WIDTH) + fill_len = max(0, width - len(title) - 2) + half = fill_len // 2 + line = "=" * half + " " + title + " " + "=" * (fill_len - half) + print(f"\n{line}") + + def format_number_like_json(self, x: Optional[float]) -> str: + if x is None: + return "N/A" + return json.dumps(x) + + def build_summary( + self, + eval_set: EvalSet, + eval_results_by_eval_id: dict[str, list[EvalCaseResult]], + agent_name: str, + num_runs: int, + ) -> dict[str, Any]: + overall_status = "passed" + eval_cases: list[dict[str, Any]] = [] + + for eval_id, results_list in eval_results_by_eval_id.items(): + if not results_list: + continue + metric_agg: dict[str, list[tuple[Optional[float], float, EvalStatus]]] = {} + for ecr in results_list: + for emr in ecr.overall_eval_metric_results: + if emr.metric_name not in metric_agg: + metric_agg[emr.metric_name] = [] + metric_agg[emr.metric_name].append((emr.score, emr.threshold, emr.eval_status)) + case_metric_results: list[dict[str, Any]] = [] + case_passed = True + for metric_name, vals in metric_agg.items(): + scores = [v[0] for v in vals if v[0] is not None] + threshold = vals[0][1] if vals else 0.0 + avg_score = statistics.mean(scores) if scores else None + if avg_score is not None and avg_score >= threshold: + status = EvalStatus.PASSED + elif avg_score is None: + status = EvalStatus.NOT_EVALUATED + else: + status = EvalStatus.FAILED + case_passed = False + case_metric_results.append({ + "metric_name": metric_name, + "score": avg_score, + "threshold": threshold, + "eval_status": self.eval_status_str(status), + }) + case_overall = "passed" if case_passed else "failed" + if not case_passed: + overall_status = "failed" + eval_cases.append({ + "eval_case_id": eval_id, + "overall_status": case_overall, + "metric_results": case_metric_results, + }) + + eval_cases.sort(key=lambda c: c["eval_case_id"]) + + return { + "agent_name": agent_name, + "eval_set_id": eval_set.eval_set_id, + "overall_status": overall_status, + "runs": num_runs, + "eval_cases": eval_cases, + } + + def summary_to_export_dict(self, summary: dict[str, Any]) -> dict[str, Any]: + """Convert summary to dict with camelCase keys for JSON export.""" + return { + "agentName": + summary["agent_name"], + "evalSetId": + summary["eval_set_id"], + "overallStatus": + summary["overall_status"], + "runs": + summary["runs"], + "evalCases": [{ + "evalCaseId": + c["eval_case_id"], + "overallStatus": + c["overall_status"], + "metricResults": [{ + "metricName": m["metric_name"], + "score": m["score"], + "threshold": m["threshold"], + "evalStatus": m["eval_status"], + } for m in c["metric_results"]], + } for c in summary["eval_cases"]], + } + + def _convert_content_to_text(self, content: Optional[Content]) -> str: + if content and content.parts: + return "\n".join([part.text for part in content.parts if part.text]) + return "" + + def _convert_tool_calls_to_text(self, intermediate_data: Optional[IntermediateDataType]) -> str: + tool_calls = get_all_tool_calls(intermediate_data) + return "\n".join([str(t) for t in tool_calls]) + + def _format_vertical_field( + self, + label: str, + value: Optional[str], + indent: str = VERTICAL_FIELD_INDENT, + max_len: int = VERTICAL_FIELD_MAX_LEN, + prefix: str = VERTICAL_FIELD_PREFIX, + ) -> str: + if not value: + value = "" + text = (value if len(value) <= max_len else value[:max_len].rstrip() + "\n... (truncated)") + lines = text.splitlines() + if not lines: + return f"{prefix}{label}:" + result = [f"{prefix}{label}: {lines[0]}"] + for line in lines[1:]: + result.append(prefix + indent + line) + return "\n".join(result) + + def _format_invocation_field(self, label: str, value: Optional[str]) -> str: + """Format a single field in an invocation block (fixed indent/prefix).""" + return self._format_vertical_field( + label, + value, + indent=INVOCATION_FIELD_INDENT, + prefix=INVOCATION_FIELD_PREFIX, + ) + + def _invocation_block_lines( + self, + exp_inv: Invocation, + act_inv: Invocation, + invocation_id: str, + ) -> list[str]: + prompt = self._convert_content_to_text(exp_inv.user_content) + expected_response = self._convert_content_to_text(exp_inv.final_response) + actual_response = self._convert_content_to_text(act_inv.final_response) + expected_tool_calls = self._convert_tool_calls_to_text(exp_inv.intermediate_data) + actual_tool_calls = self._convert_tool_calls_to_text(act_inv.intermediate_data) + return [ + f" --- Invocation id: {invocation_id} ---", + self._format_invocation_field("prompt", prompt), + self._format_invocation_field("expected_response", expected_response), + self._format_invocation_field("actual_response", actual_response), + self._format_invocation_field("expected_tool_calls", expected_tool_calls), + self._format_invocation_field("actual_tool_calls", actual_tool_calls), + "", + ] + + def process_metrics_and_get_failures( + self, + eval_metric_results: dict[str, list[MetricRunRecord]], + print_detailed_results: bool, + agent_module: str, + eval_id: str = "", + eval_set_id: str = "", + details_sink: Optional[list[str]] = None, + ) -> list[str]: + failures = [] + + if print_detailed_results and eval_metric_results and details_sink is not None: + details_sink.append("") + prefix = f"=== Eval Set: {eval_set_id} | " if eval_set_id else "=== " + details_sink.append(f"{prefix}Case id: {eval_id} ===") + + for metric_name, eval_metric_results_with_invocations in eval_metric_results.items(): + if not eval_metric_results_with_invocations: + continue + + threshold = eval_metric_results_with_invocations[0].eval_metric_result.threshold + scores = [ + m.eval_metric_result.score for m in eval_metric_results_with_invocations + if m.eval_metric_result.score is not None + ] + + if scores: + overall_score = statistics.mean(scores) + overall_eval_status = (EvalStatus.PASSED if overall_score >= threshold else EvalStatus.FAILED) + else: + overall_score = None + overall_eval_status = EvalStatus.NOT_EVALUATED + + if print_detailed_results and details_sink is not None: + status_str = self.eval_status_str(overall_eval_status) + score_str = self.format_number_like_json(overall_score) + thresh_str = self.format_number_like_json(threshold) + details_sink.append(f" [Metric] {metric_name}: {status_str}, score {score_str} " + f"(threshold {thresh_str})") + + if overall_eval_status != EvalStatus.PASSED: + failures.append(f"{metric_name} for {agent_module} Failed. Expected {threshold}, " + f"but got {overall_score}.") + + if print_detailed_results and eval_metric_results and details_sink is not None: + first_metric_list = next(iter(eval_metric_results.values())) + num_runs = len(first_metric_list) + for run_idx in range(num_runs): + item = first_metric_list[run_idx] + exp_inv = item.expected_invocation + act_inv = item.actual_invocation + raw_id = ((exp_inv.invocation_id if exp_inv.invocation_id else None) + or (act_inv.invocation_id if act_inv.invocation_id else None) or str(run_idx)) + invocation_id = (f"{raw_id} (run {run_idx + 1})" if num_runs > 1 else raw_id) + details_sink.extend(self._invocation_block_lines(exp_inv, act_inv, invocation_id)) + + return failures + + def _evaluation_result_header_lines(self, agent_name: str, num_runs: int) -> list[str]: + """Return the 3 header lines: ✅ Evaluation completed, Agent Name, Runs.""" + w = _result_label_width() + return [ + "✅ Evaluation completed", + f"{'Agent Name':<{w}}: {agent_name}", + f"{'Runs':<{w}}: {num_runs}", + ] + + def build_evaluation_result_lines( + self, + summary: dict[str, Any], + include_completed_line: bool = True, + include_agent_runs: bool = True, + ) -> list[str]: + width = _result_label_width() + lines = [] + header = self._evaluation_result_header_lines(summary["agent_name"], summary["runs"]) + if include_completed_line: + lines.append(header[0]) + if include_agent_runs: + lines.extend(header[1:3]) + lines.extend([ + f"{'Eval Set':<{width}}: {summary['eval_set_id']}", + f"{'Overall Status':<{width}}: {summary['overall_status']}", + ]) + for case in summary["eval_cases"]: + lines.append(f"Case {case['eval_case_id']} -> {case['overall_status']}") + for mr in case["metric_results"]: + score_str = self.format_number_like_json(mr["score"]) + thresh_str = self.format_number_like_json(mr["threshold"]) + lines.append(f" Metric {mr['metric_name']}: score {score_str} " + f"(threshold {thresh_str}) => {mr['eval_status']}") + lines.append("") + return lines + + def print_evaluation_result(self, summary: dict[str, Any]) -> None: + self.print_section_header("Evaluation Result") + print("") + print("\n".join(self.build_evaluation_result_lines(summary))) + + def _print_blocks(self, blocks: list[list[str]]) -> None: + """Print each block's lines with a blank line between blocks.""" + for i, block_lines in enumerate(blocks): + if i > 0: + print("") + print("\n".join(block_lines)) + + def _print_lines_blocks(self, blocks: list[tuple[str, list[str]]], section_title: str) -> None: + """Print section header then each block's lines with blank line between.""" + if not blocks: + return + self.print_section_header(section_title) + print("") + self._print_blocks([lines for _, lines in blocks]) + + def print_evaluation_report( + self, + all_details: list[tuple[str, list[str]]], + all_results: list[tuple[str, list[str]]], + display_agent_name: str, + num_runs: int, + ) -> None: + """Print Execution Details and Evaluation Result sections (multi-evalset).""" + self._print_lines_blocks(all_details, "Execution Details") + if all_results: + self.print_section_header("Evaluation Result") + print("") + print("\n".join(self._evaluation_result_header_lines(display_agent_name, num_runs))) + print("") + self._print_blocks([lines for _, lines in all_results]) diff --git a/trpc_agent_sdk/events/__init__.py b/trpc_agent_sdk/events/__init__.py new file mode 100644 index 000000000..5fde374db --- /dev/null +++ b/trpc_agent_sdk/events/__init__.py @@ -0,0 +1,27 @@ +# 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. +"""Events for TRPC Agent framework.""" + +from trpc_agent_sdk.types import EventActions + +from ._agent_cancelled_event import AgentCancelledEvent +from ._cache_analyzer import CacheMetrics +from ._cache_analyzer import analyze_cache_performance +from ._event import Event +from ._event_translator import EventTranslatorBase +from ._long_running_event import LongRunningEvent +from ._utils import create_text_event + +__all__ = [ + "EventActions", + "AgentCancelledEvent", + "CacheMetrics", + "analyze_cache_performance", + "Event", + "EventTranslatorBase", + "LongRunningEvent", + "create_text_event", +] diff --git a/trpc_agent_sdk/events/_agent_cancelled_event.py b/trpc_agent_sdk/events/_agent_cancelled_event.py new file mode 100644 index 000000000..7d25e2313 --- /dev/null +++ b/trpc_agent_sdk/events/_agent_cancelled_event.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 Apache-2.0. +"""Agent cancellation event for TRPC Agent framework.""" + +from typing import Optional + +from ._event import Event + + +class AgentCancelledEvent(Event): + """Represents an agent run that was cancelled by user request. + + This event is yielded when a run is cancelled at a checkpoint, + indicating that the agent execution was stopped cooperatively. + + Attributes: + error_code: Set to "run_cancelled" to indicate cancellation. + error_message: The reason for cancellation. + invocation_id: The invocation ID of the cancelled run. + author: The agent that was running when cancelled. + branch: The branch of the event (optional). + """ + + def __init__( + self, + invocation_id: str, + author: str, + reason: str = "Run cancelled by user", + branch: Optional[str] = None, + **kwargs, + ): + """Initialize an AgentCancelledEvent. + + Args: + invocation_id: The invocation ID of the cancelled run. + author: The agent that was running when cancelled. + reason: The cancellation reason (default: "Run cancelled by user"). + branch: The branch of the event (optional). + **kwargs: Additional keyword arguments passed to Event. + """ + super().__init__(invocation_id=invocation_id, + author=author, + error_code="run_cancelled", + error_message=reason, + branch=branch, + **kwargs) + + def model_post_init(self, __context): + """Post initialization logic for the event.""" + # Call parent's post_init first + super().model_post_init(__context) + + # Set error_code and error_message if not already set + if not self.error_code: + self.error_code = "run_cancelled" + if not self.error_message: + self.error_message = "Run cancelled by user" diff --git a/trpc_agent_sdk/events/_cache_analyzer.py b/trpc_agent_sdk/events/_cache_analyzer.py new file mode 100644 index 000000000..85f952aa9 --- /dev/null +++ b/trpc_agent_sdk/events/_cache_analyzer.py @@ -0,0 +1,92 @@ +# 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. +"""Cache performance analyzer for TRPC Agent framework.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field +from typing import TYPE_CHECKING +from typing import Iterable + +if TYPE_CHECKING: + from ._event import Event + + +@dataclass +class CacheMetrics: + """Aggregate cache performance metrics computed from a sequence of events. + + All ratio fields are percentages in the range ``[0.0, 100.0]``. + When no events carry usage metadata, all fields are zero. + + Attributes: + total_requests: Events that carry a ``usage_metadata`` object. + requests_with_cache_hits: Events where ``cache_read_input_tokens > 0``. + total_prompt_tokens: Sum of all ``prompt_token_count`` values. + total_cache_read_tokens: Sum of all ``cache_read_input_tokens`` values. + total_cache_creation_tokens: Sum of all ``cache_creation_input_tokens`` values. + cache_hit_ratio: ``total_cache_read_tokens / total_prompt_tokens * 100``. + cache_utilization_ratio: ``requests_with_cache_hits / total_requests * 100``. + avg_cached_tokens_per_request: ``total_cache_read_tokens / total_requests``. + """ + + total_requests: int = 0 + requests_with_cache_hits: int = 0 + total_prompt_tokens: int = 0 + total_cache_read_tokens: int = 0 + total_cache_creation_tokens: int = 0 + cache_hit_ratio: float = field(default=0.0) + cache_utilization_ratio: float = field(default=0.0) + avg_cached_tokens_per_request: float = field(default=0.0) + + +def analyze_cache_performance(events: Iterable[Event]) -> CacheMetrics: + """Compute cache performance metrics from an iterable of events. + + Events without ``usage_metadata`` are skipped. Missing cache token fields + default to ``0``. Division by zero is avoided; ratio fields stay ``0.0`` + when the denominator would be zero. + + Args: + events: Any iterable of :class:`~trpc_agent_sdk.events.Event` objects, + e.g. ``session.events`` or the list collected from an agent run. + + Returns: + A :class:`CacheMetrics` snapshot. + + Example:: + + from trpc_agent_sdk.events import analyze_cache_performance + + metrics = analyze_cache_performance(session.events) + print(f"Cache hit ratio: {metrics.cache_hit_ratio:.1f}%") + """ + metrics = CacheMetrics() + + for event in events: + usage = event.usage_metadata + if usage is None: + continue + + metrics.total_requests += 1 + metrics.total_prompt_tokens += usage.prompt_token_count or 0 + + cache_read = usage.cache_read_input_tokens or 0 + metrics.total_cache_read_tokens += cache_read + if cache_read > 0: + metrics.requests_with_cache_hits += 1 + + cache_creation = usage.cache_creation_input_tokens or 0 + metrics.total_cache_creation_tokens += cache_creation + + if metrics.total_prompt_tokens > 0: + metrics.cache_hit_ratio = metrics.total_cache_read_tokens / metrics.total_prompt_tokens * 100.0 + if metrics.total_requests > 0: + metrics.cache_utilization_ratio = metrics.requests_with_cache_hits / metrics.total_requests * 100.0 + metrics.avg_cached_tokens_per_request = metrics.total_cache_read_tokens / metrics.total_requests + + return metrics diff --git a/trpc_agent_sdk/events/_event.py b/trpc_agent_sdk/events/_event.py new file mode 100644 index 000000000..88f5b10e8 --- /dev/null +++ b/trpc_agent_sdk/events/_event.py @@ -0,0 +1,284 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Event class for TRPC Agent framework.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict +from pydantic import Field +from pydantic import alias_generators + +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.models import TOOL_STREAMING_ARGS +from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse + +_EVENT_FLAG_MODEL_VISIBLE = 1 << 0 +_EVENT_FLAG_SUMMARY = 1 << 1 + + +class Event(LlmResponse): + """Represents an event in a conversation between agents and users. + + It is used to store the content of the conversation, as well as the actions + taken by the agents like function calls, etc. + + Attributes: + invocation_id: Required. The invocation ID of the event. Should be non-empty + before appending to a session. + author: Required. "user" or the name of the agent, indicating who appended + the event to the session. + actions: The actions taken by the agent. + long_running_tool_ids: The ids of the long running function calls. + branch: The branch of the event. + id: The unique identifier of the event. + timestamp: The timestamp of the event. + visible: Whether the event is visible to outside observers. Default is True. + model_flags: Bit flags controlling model visibility and summary state. + request_id: Optional request ID for tracking across system boundaries. + parent_invocation_id: Optional parent invocation ID for nested agent executions. + tag: Optional business-specific labels for filtering/routing. + filter_key: Optional hierarchical event filtering identifier. + requires_completion: Whether this event needs completion signaling. + version: Version for handling compatibility issues. + is_final_response: Whether the event is the final response of the agent. + get_function_calls: Returns the function calls in the event. + get_function_responses: Returns the function responses in the event. + get_text: Returns the concatenated text content from all text parts. + """ + + model_config = ConfigDict( + extra="forbid", + ser_json_bytes="base64", + val_json_bytes="base64", + alias_generator=alias_generators.to_camel, + populate_by_name=True, + ) + """The pydantic model config.""" + + invocation_id: str = "" + """The invocation ID of the event. Should be non-empty before appending to a session.""" + + author: str = "" + """'user' or the name of the agent, indicating who appended the event to the + session.""" + + actions: EventActions = Field(default_factory=EventActions) + """The actions taken by the agent.""" + + long_running_tool_ids: Optional[set[str]] = None + """Set of ids of the long running function calls. + Agent client will know from this field about which function call is long running. + only valid for function call event + """ + + branch: Optional[str] = None + """The branch of the event. + + The format is like agent_1.agent_2.agent_3, where agent_1 is the parent of + agent_2, and agent_2 is the parent of agent_3. + + Branch is used when multiple sub-agent shouldn't see their peer agents' + conversation history. + """ + + request_id: Optional[str] = None + """The request ID for tracking across system boundaries.""" + + parent_invocation_id: Optional[str] = None + """The parent invocation ID for nested agent executions. + + Useful for tracking parent-child relationships in multi-agent scenarios. + """ + + tag: Optional[str] = None + """Business-specific labels for filtering and routing events. + + Can be used to annotate events with custom metadata for downstream processing. + """ + + filter_key: Optional[str] = None + """Hierarchical event filtering identifier. + + Used for filtering events in multi-agent scenarios. Format follows branch-like + structure with delimiters for hierarchical filtering. + """ + + requires_completion: bool = False + """Indicates if this event needs completion signaling. + + Used by the flow to determine if additional completion handling is required. + """ + + version: int = 0 + """Version for handling compatibility issues. + + Allows the system to handle different event format versions gracefully. + """ + + # The following are computed fields. + # Do not assign the ID. It will be assigned by the session. + id: str = "" + """The unique identifier of the event.""" + + timestamp: float = Field(default_factory=lambda: datetime.now().timestamp()) + """The timestamp of the event.""" + + visible: bool = True + """Whether the event is visible when invoke runner. + + Useful for hiding message not want to show in CustomAgent. + + When set to False, the runner will skip yielding this event to keep it + hidden from external visibility. + """ + + object: Optional[str] = None + """Object type for event classification. + + Used to categorize events by their type (e.g., graph.node.start, graph.node.complete). + Provides a standardized way to identify event types alongside the legacy event_type in state_delta. + """ + + model_flags: int = _EVENT_FLAG_MODEL_VISIBLE + """Bit flags for event model-state control. + + - MODEL_VISIBLE flag controls whether this event can be seen by model history builders. + - SUMMARY flag marks this event as a summary-generated event. + """ + + def model_post_init(self, __context): + """Post initialization logic for the event.""" + # Generates a random ID for the event. + if not self.id: + self.id = Event.new_id() + + def is_final_response(self) -> bool: + """Returns whether the event is the final response of the agent.""" + if self.actions.skip_summarization or self.long_running_tool_ids: + return True + return (not self.get_function_calls() and not self.get_function_responses() and not self.partial + and not self.has_trailing_code_execution_result() and not self.has_trailing_executable_code()) + + def is_model_visible(self) -> bool: + """Returns whether the event should be visible to model history.""" + return bool(self.model_flags & _EVENT_FLAG_MODEL_VISIBLE) + + def is_summary_event(self) -> bool: + """Returns whether the event is generated as a summary event.""" + return bool(self.model_flags & _EVENT_FLAG_SUMMARY) + + def set_model_visible(self, model_visible: bool) -> None: + """Set whether this event can be seen by model history builders. + + This is intended for session summarization/compression internals. User + code should not call it directly because it can break the model-visible + history window maintained by the session summarizer. + """ + if model_visible: + self.model_flags |= _EVENT_FLAG_MODEL_VISIBLE + else: + self.model_flags &= ~_EVENT_FLAG_MODEL_VISIBLE + + def set_summary_event(self, is_summary: bool = True) -> None: + """Set whether this event is marked as a summary event.""" + if is_summary: + self.model_flags |= _EVENT_FLAG_SUMMARY + else: + self.model_flags &= ~_EVENT_FLAG_SUMMARY + + def get_function_calls(self) -> list[FunctionCall]: + """Returns the function calls in the event.""" + func_calls = [] + if self.content and self.content.parts: + for part in self.content.parts: + if part.function_call: + func_calls.append(part.function_call) + return func_calls + + def get_function_responses(self) -> list[FunctionResponse]: + """Returns the function responses in the event.""" + func_response = [] + if self.content and self.content.parts: + for part in self.content.parts: + if part.function_response: + func_response.append(part.function_response) + return func_response + + def get_text(self) -> str: + """Returns the concatenated text content from all text parts in the event. + + Returns: + Concatenated text string from all text parts. Returns empty string if no text content. + """ + if not self.content or not self.content.parts: + return "" + text_parts = [part.text for part in self.content.parts if part.text] + return "".join(text_parts) + + def has_trailing_code_execution_result(self, ) -> bool: + """Returns whether the event has a trailing code execution result.""" + if self.content: + if self.content.parts: + return self.content.parts[-1].code_execution_result is not None + return False + + def has_trailing_executable_code(self) -> bool: + """Returns whether the event contains any executable code part (code to be run).""" + if not self.content or not self.content.parts: + return False + return any(p.executable_code is not None for p in self.content.parts) + + def is_error(self) -> bool: + """Returns whether the event is an error.""" + return self.error_code is not None + + def is_streaming_tool_call(self) -> bool: + """Returns whether the event is a streaming tool call event. + + Streaming tool calls are identified by: + - event.partial = True + - content contains at least one function_call part with streaming delta args + + Returns: + True if this is a streaming tool call event, False otherwise. + """ + if not self.partial: + return False + if not self.content or not self.content.parts: + return False + # Check if any part is a streaming tool call (has function_call with streaming delta) + for part in self.content.parts: + if part.function_call: + args = part.function_call.args or {} + if TOOL_STREAMING_ARGS in args: + return True + return False + + @staticmethod + def new_id(): + return str(uuid.uuid4()) diff --git a/trpc_agent_sdk/events/_event_translator.py b/trpc_agent_sdk/events/_event_translator.py new file mode 100644 index 000000000..d37af1dcd --- /dev/null +++ b/trpc_agent_sdk/events/_event_translator.py @@ -0,0 +1,67 @@ +# 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. +"""Base class for protocol event translators.""" + +from abc import ABC +from abc import abstractmethod +from typing import AsyncGenerator +from typing import Generic +from typing import TypeVar + +from ._event import Event as TrpcEvent + +# Type variables for protocol-specific events and context +ProtocolEventT = TypeVar("ProtocolEventT") +ContextT = TypeVar("ContextT") + + +class EventTranslatorBase(ABC, Generic[ProtocolEventT, ContextT]): + """Base class for protocol event translators that convert trpc Events. + + This abstract base class defines the interface for translating trpc Events + to protocol-specific events (e.g., AG-UI BaseEvent, A2A Event). + + Users implement: + - need_translate(): Filter which events to handle + - translate(): Convert events to protocol-specific format + + The context parameter provides metadata needed for building events + (e.g., task_id, context_id for A2A; thread_id, run_id for AG-UI). + + Type Parameters: + ProtocolEventT: The type of protocol-specific event this translator produces + ContextT: The type of context object passed to translate() + """ + + @abstractmethod + def need_translate(self, event: TrpcEvent) -> bool: + """Check if this event should be translated by this translator. + + Subclasses must implement this method to determine which events + they should handle. + + Args: + event: The trpc Event to check + + Returns: + True if this translator should translate this event + """ + + @abstractmethod + async def translate( + self, + event: TrpcEvent, + context: ContextT, + ) -> AsyncGenerator[ProtocolEventT, None]: + """Translate trpc Event to protocol events. + + Args: + event: The trpc Event to translate + context: Protocol-specific context (e.g., AgUiTranslationContext, A2aTranslationContext) + + Yields: + Protocol-specific events + """ diff --git a/trpc_agent_sdk/events/_long_running_event.py b/trpc_agent_sdk/events/_long_running_event.py new file mode 100644 index 000000000..0445a5cc1 --- /dev/null +++ b/trpc_agent_sdk/events/_long_running_event.py @@ -0,0 +1,37 @@ +# 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. +"""Long Running Event.""" + +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse + +from ._event import Event + + +class LongRunningEvent(Event): + """Represents a long-running event that requires human intervention. + + This event is used to pause agent execution and wait for human input + or external processing to complete. It stores the function call and + response for resumption. + + Attributes: + function_call: The function call that triggered the long-running operation. + function_response: The response from the long-running function. + """ + + function_call: FunctionCall + """The function call that triggered the long-running operation.""" + function_response: FunctionResponse + """The response from the long-running function.""" + + def model_post_init(self, __context): + """Post initialization logic for the event.""" + # Call parent's post_init first + super().model_post_init(__context) + + # Set partial to True to indicate this is a partial response + self.partial = True diff --git a/trpc_agent_sdk/events/_utils.py b/trpc_agent_sdk/events/_utils.py new file mode 100644 index 000000000..351ef9ac4 --- /dev/null +++ b/trpc_agent_sdk/events/_utils.py @@ -0,0 +1,49 @@ +# 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. +"""Utils for events.""" + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from ._event import Event + + +def create_text_event( + ctx: InvocationContext, + text: str, + thought_text: str = None, + visible: bool = True, + save: bool = False, +) -> Event: + """Create a text event with the given content. + + Args: + ctx: The invocation context containing invocation information. + text: The text content for the event. + thought_text: The thought text content for the event. + visible: Whether this event should be visible to user (default: True). + save: Whether this event should be saved to session (default: False). + + Returns: + Event: A new event with text content and proper field initialization. + """ + # Create content with a text part + parts = [] + if thought_text: + parts.append(Part(text=thought_text, thought=True)) + parts.append(Part(text=text)) + content = Content(role="model", parts=parts) + + # Create and return the event with all necessary fields + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + content=content, + branch=ctx.branch, + visible=visible, + partial=save, + ) diff --git a/trpc_agent_sdk/exceptions/__init__.py b/trpc_agent_sdk/exceptions/__init__.py new file mode 100644 index 000000000..c4feb4c58 --- /dev/null +++ b/trpc_agent_sdk/exceptions/__init__.py @@ -0,0 +1,24 @@ +# 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. +"""Exception types for TRPC Agent framework.""" + +from ._exceptions import AgentFilterError +from ._exceptions import ArtifactServiceNotFound +from ._exceptions import ErrorCode +from ._exceptions import LLMAgentModelNotFound +from ._exceptions import ParentAgentNotFound +from ._exceptions import RunCancelledException +from ._exceptions import TrpcAgentException + +__all__ = [ + "AgentFilterError", + "ArtifactServiceNotFound", + "ErrorCode", + "LLMAgentModelNotFound", + "ParentAgentNotFound", + "RunCancelledException", + "TrpcAgentException", +] diff --git a/trpc_agent_sdk/exceptions/_exceptions.py b/trpc_agent_sdk/exceptions/_exceptions.py new file mode 100644 index 000000000..8070a128a --- /dev/null +++ b/trpc_agent_sdk/exceptions/_exceptions.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 Apache-2.0. +"""Exceptions for TRPC Agent framework.""" + +from enum import IntEnum + + +class ErrorCode(IntEnum): + + def __new__(cls, value, phrase, description=''): + obj = int.__new__(cls, value) + obj._value_ = value + + obj.phrase = phrase + obj.description = description + return obj + + # informational + OK = 0, 'OK', 'Request fulfilled, document follows' + PARENT_AGENT_NOT_FOUND = (601, 'parent agent not found', 'the parent agent of current agent not found') + AGENT_FILTER_ERROR = 602, 'agent filter error', 'the filter of agent is error name' + ARTIFACT_SERVICE_NOT_FOUND = 603, 'artifact_service not found', 'the artifact_service maybe is none' + LLM_AGENT_MODEL_NOT_FOUND = 604, 'model not found', 'the artifact not found' + RUN_CANCELLED = 605, 'run cancelled', 'the run was cancelled by user request' + + +class TrpcAgentException(Exception): + """TrpcAgent exception""" + + def __init__(self, code: ErrorCode): + super().__init__(code.phrase) + self.code = code + + def __str__(self) -> str: + """Return a string representation of the exception.""" + return f'code: {self.code}, msg: {self.code.phrase}, reason: {self.code.description}' + + +class RunCancelledException(TrpcAgentException): + """Exception raised when a run is cancelled. + + This exception is raised at cancellation checkpoints when the + cancellation manager detects that a run has been cancelled. + """ + + def __init__(self, message: str = "Run cancelled by user"): + super().__init__(ErrorCode.RUN_CANCELLED) + self.message = message + + def __str__(self) -> str: + return self.message + + +ParentAgentNotFound = TrpcAgentException(ErrorCode.PARENT_AGENT_NOT_FOUND) +AgentFilterError = TrpcAgentException(ErrorCode.AGENT_FILTER_ERROR) +ArtifactServiceNotFound = TrpcAgentException(ErrorCode.ARTIFACT_SERVICE_NOT_FOUND) +LLMAgentModelNotFound = TrpcAgentException(ErrorCode.LLM_AGENT_MODEL_NOT_FOUND) diff --git a/trpc_agent_sdk/filter/__init__.py b/trpc_agent_sdk/filter/__init__.py new file mode 100644 index 000000000..13aab2e69 --- /dev/null +++ b/trpc_agent_sdk/filter/__init__.py @@ -0,0 +1,58 @@ +# 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. +"""Filter package initialization module. + +This module exports the core filter interfaces and management utilities. +""" + +from trpc_agent_sdk.abc import FilterAsyncGenHandleType +from trpc_agent_sdk.abc import FilterAsyncGenReturnType +from trpc_agent_sdk.abc import FilterHandleType +from trpc_agent_sdk.abc import FilterResult +from trpc_agent_sdk.abc import FilterReturnType +from trpc_agent_sdk.abc import FilterType + +from ._base_filter import BaseFilter +from ._filter_runner import FilterRunner +from ._registry import FilterRegistry +from ._registry import get_agent_filter +from ._registry import get_filter +from ._registry import get_model_filter +from ._registry import get_tool_filter +from ._registry import register_agent_filter +from ._registry import register_filter +from ._registry import register_model_filter +from ._registry import register_tool_filter +from ._run_filter import AgentFilterAsyncGenHandleType +from ._run_filter import coroutine_handler_adapter +from ._run_filter import run_filters +from ._run_filter import run_stream_filters +from ._run_filter import stream_handler_adapter + +__all__ = [ + "FilterAsyncGenHandleType", + "FilterAsyncGenReturnType", + "FilterHandleType", + "FilterResult", + "FilterReturnType", + "FilterType", + "BaseFilter", + "FilterRunner", + "FilterRegistry", + "get_agent_filter", + "get_filter", + "get_model_filter", + "get_tool_filter", + "register_agent_filter", + "register_filter", + "register_model_filter", + "register_tool_filter", + "AgentFilterAsyncGenHandleType", + "coroutine_handler_adapter", + "run_filters", + "run_stream_filters", + "stream_handler_adapter", +] diff --git a/trpc_agent_sdk/filter/_base_filter.py b/trpc_agent_sdk/filter/_base_filter.py new file mode 100644 index 000000000..8585a441c --- /dev/null +++ b/trpc_agent_sdk/filter/_base_filter.py @@ -0,0 +1,239 @@ +# 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. +""" +TRPC Agent Filter System Core Abstractions. + +This module defines the fundamental building blocks for the TRPC Agent filter system, +providing: + +1. Base Classes: + - FilterABC: Abstract base class defining the filter interface + - FilterResult: Standardized container for filter outputs + +2. Type System: + - FilterType: Enumeration of filter categories + - Generic type variables for context and request/response types + - Type aliases for filter handlers and results + +3. Core Features: + - Async-first design with full async generator support + - Type-safe filter execution pipeline + - Comprehensive error handling + - Extensible filter categorization + +Example Usage: + class MyFilter(FilterABC): + async def _before(self, ctx, req): + # Pre-processing logic + yield FilterResult(...) + + async def _after(self, ctx, req): + # Post-processing logic + yield FilterResult(...) +""" + +from __future__ import annotations + +import inspect +from functools import partial +from types import AsyncGeneratorType +from types import CoroutineType +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Union +from typing_extensions import override + +from trpc_agent_sdk.abc import FilterABC +from trpc_agent_sdk.abc import FilterAsyncGenHandleType +from trpc_agent_sdk.abc import FilterAsyncGenReturnType +from trpc_agent_sdk.abc import FilterHandleType +from trpc_agent_sdk.abc import FilterResult +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.log import logger + +from ..exceptions import RunCancelledException + + +class BaseFilter(FilterABC): + """Abstract base class defining the filter interface. + + All concrete filters must implement these methods to be compatible + with the filter management system. + """ + + @override + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Execute before. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + + Returns: + None + """ + return None + + @override + async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult): + """Execute after. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + Returns: + None + """ + return None + + @override + async def _after_every_stream(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + """Execute after every stream. + + Args: + ctx: AgentContext + req: Request data + rsp: Response data, will be used to store the result of the filter + Returns: + None + """ + return None + + async def _handle_co(self, + result: FilterResult, + co: Union[CoroutineType, AsyncGeneratorType], + msg: str, + handle_event: Callable[[FilterResult], Awaitable[None]] = None) -> FilterAsyncGenReturnType: + """Execute the before lifecycle. + + Args: + ctx: Execution context + req: Request data + handle: Next handler in the chain + """ + try: + if inspect.isasyncgen(co): + async for event in co: + if not isinstance(event, FilterResult): + raise TypeError(f"{msg} result must be a FilterResult, got {type(event)}") + if handle_event: + await handle_event(event) + yield event + if event.error: + # Error passed from upper layer, use debug mode + logger.debug(self._create_err_str(f"{msg} error: {event.error}")) + if not event.is_continue: + return + else: + rsp = await co + if rsp: + if isinstance(rsp, tuple) and len(rsp) == 2: + result.rsp, result.error = rsp + if result.error: + result.is_continue = False + else: + result.rsp = rsp + if result.rsp: + yield result + if result.error: + # Error passed from upper layer, use debug mode + logger.debug(self._create_err_str(f"{msg} error: {result.error}")) + if not result.is_continue: + return + except RunCancelledException: + # raise to runner to handle + raise + except Exception as ex: # pylint: disable=broad-except + logger.error("filter type: %s, name: %s run %s error: %s", + self._type.name, + self._name, + msg, + ex, + exc_info=True) + yield FilterResult(error=ex, is_continue=False) + return + + @override + async def run_stream(self, ctx: AgentContext, req: Any, + handle: FilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + """Execute the full filter lifecycle (before -> handle -> after). + + Args: + ctx: Execution context + req: Request data + handle: Next handler in the chain + + Returns: + FilterResult: Combined result of all operations + """ + result = FilterResult() + + # run before in current filter + async for event in self._handle_co(result, self._before(ctx, req, result), "before"): + yield event + if not event.is_continue: + return + + # run last filter + handle_event = partial(self._after_every_stream, ctx, req) + async for event in self._handle_co(result, handle(), "handle", handle_event): + yield event + if not event.is_continue: + return + + # run after in current filter + async for event in self._handle_co(result, self._after(ctx, req, result), "after"): + yield event + if not event.is_continue: + return + + @override + async def run(self, ctx: AgentContext, req: Any, handle: FilterHandleType) -> FilterResult: + """Execute the full filter lifecycle (before -> handle -> after). + + Args: + ctx: Execution context + req: Request data + handle: Next handler in the chain + + Returns: + FilterResult: Combined result of all operations + """ + result = FilterResult() + # 1. before + try: + await self._before(ctx, req, result) + except Exception as ex: # pylint: disable=broad-except + logger.error(self._create_err_str(f"run before error: {ex}")) + return None, ex + if result.error or not result.is_continue: + return result + + # 2.last handle + rsp = await handle() + if isinstance(rsp, FilterResult): + result = rsp + elif isinstance(rsp, tuple) and len(rsp) == 2: + result.rsp, result.error = rsp + if result.error: + result.is_continue = False + else: + result.rsp = rsp + if result.error or not result.is_continue: + return result + + # 3. after + try: + await self._after(ctx, req, result) + except Exception as ex: # pylint: disable=broad-except + logger.error(self._create_err_str(f"run after error: {ex}")) + result.error = ex + result.is_continue = False + + return result diff --git a/trpc_agent_sdk/filter/_filter_runner.py b/trpc_agent_sdk/filter/_filter_runner.py new file mode 100644 index 000000000..aa4bac543 --- /dev/null +++ b/trpc_agent_sdk/filter/_filter_runner.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. +"""Filter runner module. + +This module defines the filter runner class, which is used to run filters. +""" + +from abc import ABC +from typing import Any +from typing import Optional +from typing import Union + +from trpc_agent_sdk.abc import FilterType +from trpc_agent_sdk.context import AgentContext + +from ._base_filter import BaseFilter +from ._registry import get_filter +from ._run_filter import AgentFilterAsyncGenHandleType +from ._run_filter import AgentFilterHandleType +from ._run_filter import run_filters +from ._run_filter import run_stream_filters + + +class FilterRunner(ABC): + """Filter runner + + Args: + filters_name: List of filter names. + filters: List of filter instances. + """ + + def __init__(self, filters_name: Optional[list[str]] = None, filters: Optional[list[BaseFilter]] = None): + self._filters_name = filters_name or [] + self._filters: list[BaseFilter] = filters or [] + self._name = self.__class__.__name__ + self._type = FilterType.UNSUPPORTED + + @property + def filters_name(self) -> list[str]: + """Get filter name.""" + return self._filters_name + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str): + self._name = value + + def _init_filters(self): + """Initialize filters.""" + for filter_name in self._filters_name: + filter_instance = get_filter(self._type, filter_name) + if not filter_instance: + raise ValueError(f"Filter {filter_name} not found, type: {self._type}") + self._filters.append(filter_instance) + + @property + def filters(self) -> list[BaseFilter]: + """Get filters.""" + return self._filters + + def add_filters(self, filters: list[Union[BaseFilter, str]], force: bool = False): + """Add filters.""" + filter_list = [] + for filter in filters or []: + if isinstance(filter, str): + filter_instance = get_filter(self._type, filter) + if not filter_instance: + raise ValueError(f"Filter {filter} not found, type: {self._type}") + filter_list.append(filter_instance) + else: + filter_list.append(filter) + if force: + self._filters = filter_list + else: + self._filters.extend(filter_list) # type: ignore + + def add_one_filter(self, filter: Union[BaseFilter, str], index: Optional[int] = None, force: bool = False): + """Add one filter.""" + if isinstance(filter, str): + filter_instance = get_filter(self._type, filter) + if not filter_instance: + raise ValueError(f"Filter {filter} not found, type: {self._type}") + else: + filter_instance = filter + if not force: + for f in self._filters: + if f.name == filter_instance.name: + return + if index is None: + self._filters.append(filter_instance) + else: + self._filters.insert(index, filter_instance) + + def get_filter(self, filter_name: str) -> BaseFilter: + """Get filter.""" + for filter in self._filters: + if filter.name == filter_name: + return filter + raise ValueError(f"Filter {filter_name} not found") + + async def _run_filters(self, + ctx: AgentContext, + req: Any, + handle: AgentFilterHandleType, + extra_filters: Optional[list[BaseFilter]] = None) -> Any: + """Run filters.""" + filters = self._filters.copy() + if extra_filters: + filters.extend(extra_filters) + return await run_filters(ctx, req, filters, handle) + + async def _run_stream_filters(self, + ctx: AgentContext, + req: Any, + handle: AgentFilterAsyncGenHandleType, + extra_filters: Optional[list[BaseFilter]] = None) -> Any: + """Run stream filters.""" + filters = self._filters.copy() + if extra_filters: + filters.extend(extra_filters) + async for event in run_stream_filters(ctx, req, filters, handle): + yield event diff --git a/trpc_agent_sdk/filter/_registry.py b/trpc_agent_sdk/filter/_registry.py new file mode 100644 index 000000000..bd22580ab --- /dev/null +++ b/trpc_agent_sdk/filter/_registry.py @@ -0,0 +1,175 @@ +# 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. +"""Filter registry for TRPC Agent framework.""" + +from functools import partial +from typing import Callable + +from trpc_agent_sdk.abc import FilterType +from trpc_agent_sdk.utils import BaseRegistryFactory +from trpc_agent_sdk.utils import SingletonBase + +from ._base_filter import BaseFilter + + +class _FilterManager(BaseRegistryFactory[BaseFilter]): + """Central registry for managing TRPC filters. + + This singleton class provides: + - Filter registration and lookup functionality + - Type-based filter management + - Namespace isolation for different filter types + + Attributes: + __filter_manager_cache: Dictionary mapping filter types to their managers + """ + + def __init__(self, filter_type: FilterType = FilterType.UNSUPPORTED) -> None: + super().__init__() + self._type = filter_type + + @property + def filter_type(self) -> FilterType: + """Get the filter type.""" + return self._type + + def register_filter(self, name: str) -> Callable[[type[BaseFilter]], type[BaseFilter]]: + """Decorator factory for registering filter classes. + + This decorator automatically registers filter classes with the filter management system, + associating them with a specific filter type and name. + + Args: + filter_type: The FilterType this filter belongs to (e.g., FilterType.MODEL) + name: Unique name identifier for this filter + force: Whether to overwrite existing registration if name conflicts + + Returns: + A decorator function that will register the filter class + + Example: + @register_filter(FilterType.MODEL, "my_model_filter") + class MyModelFilter(BaseFilter): + ... + """ + + def decorator(cls: type[BaseFilter]) -> type[BaseFilter]: + """Actual decorator that performs the filter registration. + + Args: + cls: The filter class to be registered (must inherit from BaseFilter) + + Returns: + The original class (for chaining) + + Raises: + AssertionError: If the class doesn't instantiate to a BaseFilter + TypeError: If filter type is invalid + """ + """Decorator that registers a filter class. + + Args: + cls: The filter class to register + + Returns: + The original class (for chaining) + + Raises: + TypeError: If filter already exists and force=False + """ + self.register(cls.__name__, cls) + filter_instance = self.create_and_save(cls.__name__, name) + assert isinstance(filter_instance, BaseFilter) + assert isinstance(name, str) + filter_instance.type = self._type + filter_instance.name = name + return cls + + # We're called as @dataclass without parens. + return decorator + + +class FilterRegistry(SingletonBase): + """Filter registry. + + This singleton class provides: + - Filter registration and lookup functionality + - Type-based filter management + - Namespace isolation for different filter types + """ + + def __init__(self) -> None: + super().__init__() + self._filter_registry: dict[FilterType, _FilterManager] = {} + # Initialize filter managers for each filter type + for t in FilterType.__members__.values(): + if t != FilterType.UNSUPPORTED: + self._filter_registry[t] = _FilterManager(t) + + def register(self, filter_type: FilterType, name: str) -> Callable[[type[BaseFilter]], type[BaseFilter]]: + """Register a filter class. + + Args: + filter_type: The FilterType this filter belongs to (e.g., FilterType.MODEL) + name: Unique name identifier for this filter + force: Whether to overwrite existing registration if name conflicts + + Returns: + A decorator function that will register the filter class + """ + return self._filter_registry[filter_type].register_filter(name) + + def get(self, filter_type: FilterType, name: str) -> BaseFilter | None: + """Get a specific filter by type and name. + + Args: + filter_type: The FilterType this filter belongs to (e.g., FilterType.MODEL) + name: The name of the filter + + Returns: + The BaseFilter instance if found, None otherwise + """ + return self._filter_registry[filter_type].get_instance(name) + + +def register_filter(filter_type: FilterType, name: str) -> Callable[[type[BaseFilter]], type[BaseFilter]]: + """Decorator factory for registering filter classes. + + This decorator automatically registers filter classes with the filter management system, + associating them with a specific filter type and name. + + Args: + filter_type: The FilterType this filter belongs to (e.g., FilterType.MODEL) + name: Unique name identifier for this filter + + Returns: + A decorator function that will register the filter class + + Example: + @register_filter(FilterType.MODEL, "my_model_filter") + class MyModelFilter(BaseFilter): + ... + """ + return FilterRegistry().register(filter_type, name) + + +def get_filter(filter_type: FilterType, name: str) -> BaseFilter | None: + """Get a specific filter by type and name. + + Args: + filter_type: The FilterType this filter belongs to (e.g., FilterType.MODEL) + name: The name of the filter + """ + return FilterRegistry().get(filter_type, name) + + +register_tool_filter = partial(register_filter, FilterType.TOOL) +register_model_filter = partial(register_filter, FilterType.MODEL) +register_agent_filter = partial(register_filter, FilterType.AGENT) + +get_tool_filter = partial(get_filter, FilterType.TOOL) +get_model_filter = partial(get_filter, FilterType.MODEL) +get_agent_filter = partial(get_filter, FilterType.AGENT) diff --git a/trpc_agent_sdk/filter/_run_filter.py b/trpc_agent_sdk/filter/_run_filter.py new file mode 100644 index 000000000..98a736453 --- /dev/null +++ b/trpc_agent_sdk/filter/_run_filter.py @@ -0,0 +1,104 @@ +# 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. +"""Run filter for TRPC Agent framework.""" + +from functools import partial +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable + +from trpc_agent_sdk.abc import FilterAsyncGenReturnType +from trpc_agent_sdk.abc import FilterResult +from trpc_agent_sdk.abc import FilterReturnType +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.log import logger + +from ._base_filter import BaseFilter + +AgentFilterHandleType = Callable[[], Awaitable[Any]] +AgentFilterAsyncGenHandleType = Callable[[], AsyncGenerator[Any, None]] # type: ignore + + +async def stream_handler_adapter(func: AgentFilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + """Adapter for agent filter async gen handle.""" + async for event in func(): + if isinstance(event, FilterResult): + yield event + else: + yield FilterResult(rsp=event, is_continue=True) + + +async def run_stream_filters(ctx: AgentContext, req: Any, filters: list[BaseFilter], + handle: AgentFilterAsyncGenHandleType) -> FilterAsyncGenReturnType: + """Run a sequence of filters on a request. + + Args: + ctx: Context object for filter execution + req: Request object for filter execution + filters: List of filters to execute + handle: Final handler to call after all filters + + Returns: + Result of the filter chain execution + + Raises: + ValueError: If handle is not provided + """ + if handle is None: + raise ValueError("handle must be provided") + current_handle = partial(stream_handler_adapter, handle) + for filter in reversed(filters): + current_handle = partial(filter.run_stream, ctx, req, current_handle) + async for event in current_handle(): + yield event.rsp + + +async def coroutine_handler_adapter(func: AgentFilterHandleType) -> FilterResult: + """Adapter for agent filter handle.""" + try: + result = await func() + except Exception as ex: # pylint: disable=broad-except + return FilterResult(error=ex, is_continue=False) + + rsp = None + error = None + if isinstance(result, FilterResult): + return result + if isinstance(result, tuple) and len(result) == 2: + rsp, error = result + else: + rsp = result + + return FilterResult(rsp=rsp, error=error) + + +async def run_filters(ctx: AgentContext, req: Any, filters: list[BaseFilter], + handle: AgentFilterHandleType) -> FilterReturnType: + """Run a sequence of filters on a request. + + Args: + ctx: Context object for filter execution + req: Request object for filter execution + filters: List of filters to execute + handle: Final handler to call after all filters + + Returns: + Result of the filter chain execution + + Raises: + ValueError: If handle is not provided + """ + if handle is None: + raise ValueError("handle must be provided") + current_handle = partial(coroutine_handler_adapter, handle) + for filter in reversed(filters): + current_handle = partial(filter.run, ctx, req, current_handle) + rsp, error = await current_handle() + if error: + logger.error("run_filters error: %s", error) + raise error + return rsp diff --git a/trpc_agent_sdk/knowledge/__init__.py b/trpc_agent_sdk/knowledge/__init__.py new file mode 100644 index 000000000..4e6beba65 --- /dev/null +++ b/trpc_agent_sdk/knowledge/__init__.py @@ -0,0 +1,22 @@ +# 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. +"""Knowledge module for TRPC Agent framework.""" + +from ._filter_expr import KnowledgeFilterExpr +from ._knowledge import KnowledgeBase +from ._knowledge import SearchDocument +from ._knowledge import SearchParams +from ._knowledge import SearchRequest +from ._knowledge import SearchResult + +__all__ = [ + "KnowledgeFilterExpr", + "KnowledgeBase", + "SearchDocument", + "SearchParams", + "SearchRequest", + "SearchResult", +] diff --git a/trpc_agent_sdk/knowledge/_filter_expr.py b/trpc_agent_sdk/knowledge/_filter_expr.py new file mode 100644 index 000000000..1d263681f --- /dev/null +++ b/trpc_agent_sdk/knowledge/_filter_expr.py @@ -0,0 +1,106 @@ +# 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. +"""Unified filter model for knowledge backends.""" + +from __future__ import annotations + +from typing import Any +from typing import Literal +from typing import TypeAlias + +from pydantic import BaseModel +from pydantic import model_validator + +_LOGICAL_OPERATORS = {"and", "or"} +_COMPARISON_OPERATORS = { + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "not in", + "like", + "not like", + "between", +} +_ALL_OPERATORS = _LOGICAL_OPERATORS | _COMPARISON_OPERATORS + +KnowledgeFilterOperator: TypeAlias = Literal[ + "eq", + "ne", + "gt", + "gte", + "lt", + "lte", + "in", + "not in", + "like", + "not like", + "between", + "and", + "or", +] + + +class KnowledgeFilterExpr(BaseModel): + """Typed knowledge filter expression.""" + + operator: KnowledgeFilterOperator + field: str = "" + value: Any = None + + @model_validator(mode="before") + @classmethod + def _normalize_operator(cls, data: Any) -> Any: + if isinstance(data, KnowledgeFilterExpr): + return data + if not isinstance(data, dict): + raise ValueError(f"knowledge filter must be an object, got {type(data)}") + + operator_raw = data.get("operator") + if not isinstance(operator_raw, str): + raise ValueError("knowledge filter operator must be a string") + operator = operator_raw.strip().lower() + if operator not in _ALL_OPERATORS: + raise ValueError( + f"unsupported knowledge filter operator: {operator!r}, " + "supported: eq, ne, gt, gte, lt, lte, in, not in, like, not like, between, and, or", ) + + normalized_data = dict(data) + normalized_data["operator"] = operator + return normalized_data + + @model_validator(mode="after") + def _validate_semantics(self) -> KnowledgeFilterExpr: + operator = self.operator + value = self.value + + if operator in _LOGICAL_OPERATORS: + if not isinstance(value, list): + raise ValueError(f"logical operator {operator!r} requires list value") + children = [KnowledgeFilterExpr.model_validate(item) for item in value] + if not children: + raise ValueError(f"logical operator {operator!r} requires non-empty child conditions") + self.field = "" + self.value = children + return self + + if self.field.strip() == "": + raise ValueError(f"comparison operator {operator!r} requires non-empty field") + self.field = self.field.strip() + + if operator in {"in", "not in"}: + if not isinstance(value, list) or len(value) == 0: + raise ValueError(f"operator {operator!r} requires non-empty list value") + elif operator == "between": + if not isinstance(value, list) or len(value) != 2: + raise ValueError("operator 'between' requires list value with exactly two items") + elif value is None: + raise ValueError(f"comparison operator {operator!r} requires value") + + return self diff --git a/trpc_agent_sdk/knowledge/_knowledge.py b/trpc_agent_sdk/knowledge/_knowledge.py new file mode 100644 index 000000000..e996739ad --- /dev/null +++ b/trpc_agent_sdk/knowledge/_knowledge.py @@ -0,0 +1,91 @@ +# 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. +"""This module provides the main knowledge management interface for trpc-agent.""" + +from abc import ABC +from abc import abstractmethod +from typing import Any +from typing import Dict +from typing import List +from typing import Optional + +from langchain_core.documents import Document +from langchain_core.messages import BaseMessage +from pydantic import BaseModel + +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.types import Part + +from ._filter_expr import KnowledgeFilterExpr + + +# SearchParams contains parameters related to search, user can customize +class SearchParams(BaseModel): + # Search type: support similarity, similarity_score_threshold, mmr + search_type: str = "similarity" + # TopP set probability cumulative threshold (optional, default value) + top_p: float = 0.8 + # rank_top_k return the number of the most related K results (optional, default value) + rank_top_k: int = 3 + # rerank_threshold minimum relevance score threshold for reranking (optional, default value) + rerank_threshold: float = 0.3 + # default_score relevance score (optional, default value) + default_score: float = 0.0 + # generator_temperature temperature parameter for the generator model (optional, default value) + generator_temperature: float = 0.0 + # generator_max_tokens maximum number of tokens output by the generator model (optional, default value) + generator_max_tokens: int = 5000 + # extra_params used to store implementation-specific parameters (user-defined) + extra_params: Optional[Dict[str, Any]] = {} + + +# SearchRequest represents a search request with context +class SearchRequest(BaseModel): + # query is the content submitted for search + query: Part = None + + # history contains the most recent conversation messages as context. + history: List[BaseMessage] = [] + + # user_id can be used for personalized search results + user_id: str = "" + + # session_id can be used for session-specific context + session_id: str = "" + + # params user can customize parameters related to search + params: SearchParams = SearchParams() + + +# SearchDocument represents a single document searched with relevance score +class SearchDocument(BaseModel): + # document is the single document matched + document: Document = None + # relevance score + score: float = 0.0 + + +# SearchResult represents the result of knowledge search +class SearchResult(BaseModel): + # documents are the multiple documents matched + documents: List[SearchDocument] = [] + + +class KnowledgeBase(ABC): + + @abstractmethod + async def search(self, ctx: AgentContext, req: SearchRequest) -> SearchResult: + """ + Execute semantic search and return the best result, this is the main method for Agent to use RAG. + + :param ctx: context, contains conversation history for better understanding. + :param req: request object, contains query and context information. + :return SearchResult: search result. + """ + + def build_search_extra_params(self, filter_expr: Optional[KnowledgeFilterExpr]) -> Dict[str, Any]: + """Build backend-specific extra parameters from a unified filter expression.""" + return {} diff --git a/trpc_agent_sdk/log/__init__.py b/trpc_agent_sdk/log/__init__.py new file mode 100644 index 000000000..d064018de --- /dev/null +++ b/trpc_agent_sdk/log/__init__.py @@ -0,0 +1,44 @@ +# 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. +""" +TRPC Agent Logging Module + +This module provides a unified logging system for the trpc_agent_sdk framework. +""" + +from . import _logger as logger +from ._base_logger import BaseLogger +from ._base_logger import LogLevel +from ._default_logger import DefaultLogger +from ._default_logger import RelativePathFormatter +from ._logger import debug +from ._logger import error +from ._logger import fatal +from ._logger import get_logger +from ._logger import info +from ._logger import register_logger +from ._logger import set_default_logger +from ._logger import set_logger +from ._logger import warning +from ._logger import with_fields + +__all__ = [ + "logger", + "BaseLogger", + "LogLevel", + "DefaultLogger", + "RelativePathFormatter", + "debug", + "error", + "fatal", + "get_logger", + "info", + "register_logger", + "set_default_logger", + "set_logger", + "warning", + "with_fields", +] diff --git a/trpc_agent_sdk/log/_base_logger.py b/trpc_agent_sdk/log/_base_logger.py new file mode 100644 index 000000000..617b32ed5 --- /dev/null +++ b/trpc_agent_sdk/log/_base_logger.py @@ -0,0 +1,124 @@ +# 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. +"""Base logger interface for TRPC Agent framework. + +This module defines the abstract base class that all loggers in the trpc_agent_sdk +framework must implement. It provides a unified interface for logging operations. +""" + +from abc import ABC +from abc import abstractmethod +from enum import Enum +from enum import unique + + +@unique +class LogLevel(Enum): + """Log level enumeration.""" + TRACE = 1 + DEBUG = 2 + INFO = 3 + WARNING = 4 + ERROR = 5 + FATAL = 6 + + +class BaseLogger(ABC): + """Abstract base class for all loggers in TRPC Agent framework. + + This class defines the interface that all logger implementations must follow. + It provides methods for different log levels and allows for custom metadata. + + Attributes: + name: The logger name identifier + min_level: Minimum log level to process + """ + + def __init__(self, name: str = "default", min_level: LogLevel = LogLevel.DEBUG): + """Initialize the base logger. + + Args: + name: Logger name identifier + min_level: Minimum log level to process + """ + self.name = name + self.min_level = min_level + + @abstractmethod + def debug(self, format_str: str, *args, **kwargs): + """Log a debug message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + pass + + @abstractmethod + def info(self, format_str: str, *args, **kwargs): + """Log an info message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + pass + + @abstractmethod + def warning(self, format_str: str, *args, **kwargs): + """Log a warning message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + pass + + @abstractmethod + def error(self, format_str: str, *args, **kwargs): + """Log an error message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + pass + + @abstractmethod + def fatal(self, format_str: str, *args, **kwargs): + """Log a fatal message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + pass + + def set_level(self, level: LogLevel): + """Set the minimum log level. + + Args: + level: Minimum log level to process + """ + self.min_level = level + + def with_fields(self, **kwargs) -> 'BaseLogger': + """Create a logger with additional fields. + + Args: + **kwargs: Additional fields to include in logs + + Returns: + BaseLogger: Logger instance with additional fields + """ + # Default implementation returns self + # Subclasses can override to provide field support + return self diff --git a/trpc_agent_sdk/log/_default_logger.py b/trpc_agent_sdk/log/_default_logger.py new file mode 100644 index 000000000..5bbdd2e1c --- /dev/null +++ b/trpc_agent_sdk/log/_default_logger.py @@ -0,0 +1,228 @@ +# 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. +"""Default logger implementation for TRPC Agent framework. + +This module provides a default logger implementation that uses Python's +standard logging module as the backend. It serves as the reference +implementation and fallback logger for the trpc_agent_sdk framework. +""" + +import logging +import os +import site +import sys +from typing import Any +from typing import Dict +from typing import Optional + +from ._base_logger import BaseLogger +from ._base_logger import LogLevel + + +class RelativePathFormatter(logging.Formatter): + """Custom formatter that converts absolute paths to relative paths. + + This formatter detects the base path (venv root or project root) and + converts absolute file paths to relative paths for cleaner log output. + """ + + def __init__(self, fmt=None, datefmt=None, base_path=None): + """Initialize the formatter. + + Args: + fmt: Log format string + datefmt: Date format string + base_path: Base path for relative path calculation. If None, auto-detected. + """ + super().__init__(fmt, datefmt) + self.base_path = base_path or self._detect_base_path() + + def _detect_base_path(self) -> str: + """Detect the base path (project root containing venv). + + Returns: + str: The detected base path + """ + # Try to find project root from site-packages + # e.g., /project/venv/lib/python3.x/site-packages -> /project + try: + site_packages = site.getsitepackages() + if site_packages: + # Go up to venv root, then one more level to project root + # site-packages -> lib -> python3.x -> venv -> project + venv_root = os.path.dirname(os.path.dirname(os.path.dirname(site_packages[0]))) + project_root = os.path.dirname(venv_root) + if os.path.isdir(project_root): + return project_root + except Exception: # pylint: disable=broad-except + pass + + # Fallback to current working directory + return os.getcwd() + + def format(self, record: logging.LogRecord) -> str: + """Format the log record with relative path. + + Args: + record: The log record to format + + Returns: + str: The formatted log message + """ + # Convert absolute pathname to relative only if file is inside base path + if record.pathname and self.base_path: + try: + abs_pathname = os.path.abspath(record.pathname) + abs_base = os.path.abspath(self.base_path) + # Only use relative path if file is inside base path + if abs_pathname.startswith(abs_base + os.sep): + record.pathname = os.path.relpath(abs_pathname, abs_base) + except ValueError: + # On Windows, relpath fails across different drives + pass + return super().format(record) + + +class DefaultLogger(BaseLogger): + """Default logger implementation using Python's standard logging. + + This logger uses Python's built-in logging module as the backend and + provides a simple, reliable logging solution for trpc_agent_sdk. + + Attributes: + logger: The underlying Python logger instance + extra_fields: Additional fields to include in all log messages + """ + + def __init__(self, + name: str = "trpc_agent_sdk", + min_level: LogLevel = LogLevel.INFO, + extra_fields: Optional[Dict[str, Any]] = None): + """Initialize the default logger. + + Args: + name: Logger name identifier + min_level: Minimum log level to process + extra_fields: Additional fields to include in all log messages + """ + super().__init__(name, min_level) + self.logger = logging.getLogger(name) + self.extra_fields = extra_fields or {} + + # Set up the logger if it hasn't been configured + if not self.logger.handlers: + self._setup_logger() + + def _setup_logger(self): + """Set up the Python logger with default configuration.""" + # Create console handler + console_handler = logging.StreamHandler(sys.stdout) + + # Create formatter with relative path support + # Use standard logging format specifiers with stacklevel for correct caller location + formatter = RelativePathFormatter( + '[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + console_handler.setFormatter(formatter) + + # Add handler to logger + self.logger.addHandler(console_handler) + + # Set level + self.logger.setLevel(self._convert_log_level(self.min_level)) + + # Prevent propagation to avoid duplicate logs + self.logger.propagate = False + + def _convert_log_level(self, level: LogLevel) -> int: + """Convert LogLevel enum to Python logging level. + + Args: + level: LogLevel enum value + + Returns: + int: Python logging level constant + """ + mapping = { + LogLevel.TRACE: logging.DEBUG, + LogLevel.DEBUG: logging.DEBUG, + LogLevel.INFO: logging.INFO, + LogLevel.WARNING: logging.WARNING, + LogLevel.ERROR: logging.ERROR, + LogLevel.FATAL: logging.CRITICAL, + } + return mapping.get(level, logging.INFO) + + def _ensure_extra_fields(self, **kwargs) -> Dict[str, Any]: + """Ensure extra fields are properly formatted. + + Args: + **kwargs: Keyword arguments that may contain 'extra' dict + + Returns: + Dict[str, Any]: Updated kwargs with properly formatted extra fields + """ + if 'extra' not in kwargs: + kwargs['extra'] = {} + + # Add any additional fields + kwargs['extra'].update(self.extra_fields) + + return kwargs + + def debug(self, format_str: str, *args, **kwargs): + """Log a debug message.""" + if self.min_level.value <= LogLevel.DEBUG.value: + kwargs = self._ensure_extra_fields(**kwargs) + stacklevel = kwargs.pop('stacklevel', 1) + self.logger.debug(format_str, *args, stacklevel=stacklevel, **kwargs) + + def info(self, format_str: str, *args, **kwargs): + """Log an info message.""" + if self.min_level.value <= LogLevel.INFO.value: + kwargs = self._ensure_extra_fields(**kwargs) + stacklevel = kwargs.pop('stacklevel', 1) + self.logger.info(format_str, *args, stacklevel=stacklevel, **kwargs) + + def warning(self, format_str: str, *args, **kwargs): + """Log a warning message.""" + if self.min_level.value <= LogLevel.WARNING.value: + kwargs = self._ensure_extra_fields(**kwargs) + stacklevel = kwargs.pop('stacklevel', 1) + self.logger.warning(format_str, *args, stacklevel=stacklevel, **kwargs) + + def error(self, format_str: str, *args, **kwargs): + """Log an error message.""" + if self.min_level.value <= LogLevel.ERROR.value: + kwargs = self._ensure_extra_fields(**kwargs) + stacklevel = kwargs.pop('stacklevel', 1) + self.logger.error(format_str, *args, stacklevel=stacklevel, **kwargs) + + def fatal(self, format_str: str, *args, **kwargs): + """Log a fatal message.""" + if self.min_level.value <= LogLevel.FATAL.value: + kwargs = self._ensure_extra_fields(**kwargs) + stacklevel = kwargs.pop('stacklevel', 1) + self.logger.critical(format_str, *args, stacklevel=stacklevel, **kwargs) + + def set_level(self, level: LogLevel): + """Set the minimum log level.""" + super().set_level(level) + self.logger.setLevel(self._convert_log_level(level)) + + def with_fields(self, **kwargs) -> 'DefaultLogger': + """Create a logger with additional fields. + + Args: + **kwargs: Additional fields to include in logs + + Returns: + DefaultLogger: New logger instance with additional fields + """ + new_extra_fields = self.extra_fields.copy() + new_extra_fields.update(kwargs) + + return DefaultLogger(name=self.name, min_level=self.min_level, extra_fields=new_extra_fields) diff --git a/trpc_agent_sdk/log/_logger.py b/trpc_agent_sdk/log/_logger.py new file mode 100644 index 000000000..90ce0d987 --- /dev/null +++ b/trpc_agent_sdk/log/_logger.py @@ -0,0 +1,189 @@ +# 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. +"""Unified logger module for TRPC Agent framework. + +This module provides the main logging interface for the trpc_agent_sdk framework. +It offers global logging functions that can be used throughout the codebase +and provides logger management capabilities. + +Usage: + import trpc_agent_sdk.log.logger as logger + + logger.info("This is an info message") + logger.debug("Debug message with args: %s", some_value) + logger.error("Error occurred", extra={"custom_field": "value"}) + + # Set custom logger + from trpc_agent_sdk.abc import BaseLogger + custom_logger = MyCustomLogger() + logger.set_logger(custom_logger) +""" + +import sys +from typing import Dict +from typing import Optional + +from ._base_logger import BaseLogger +from ._default_logger import DefaultLogger + +# Global logger registry +_loggers: Dict[str, BaseLogger] = {} # pylint: disable=invalid-name +_default_logger_name: str = "default" # pylint: disable=invalid-name +_current_logger: Optional[BaseLogger] = None # pylint: disable=invalid-name + +# Initialize with default logger +_default_logger: BaseLogger = DefaultLogger(name="trpc_agent_sdk") # pylint: disable=invalid-name +_loggers[_default_logger_name] = _default_logger # pylint: disable=invalid-name +_current_logger: BaseLogger = _default_logger # pylint: disable=invalid-name + + +def get_logger(name: Optional[str] = None) -> BaseLogger: + """Get a logger by name. + + Args: + name: Logger name. If None, returns the current default logger. + + Returns: + BaseLogger: The requested logger instance + """ + if name is None: + return _current_logger + + if name not in _loggers: + warning("Logger '%s' not found, using default logger", name) + return _current_logger + + return _loggers[name] + + +def set_logger(logger: BaseLogger, name: Optional[str] = None): + """Set a logger instance. + + Args: + logger: The logger instance to set + name: Logger name. If None, sets as the default logger. + """ + global _current_logger # pylint: disable=invalid-name + + if name is None: + name = _default_logger_name + _current_logger = logger + + _loggers[name] = logger + + +def register_logger(name: str, logger: BaseLogger): + """Register a named logger. + + Args: + name: Logger name + logger: The logger instance to register + """ + _loggers[name] = logger + + +# Stack level for logging to show correct caller location +# The call stack is: user_code -> logger.info() -> _current_logger.info() -> self.logger.info() +# So we need stacklevel=3 to skip the wrapper functions +_STACK_LEVEL = 3 + + +def debug(format_str: str, *args, **kwargs): + """Log a debug message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + try: + kwargs['stacklevel'] = _STACK_LEVEL + _current_logger.debug(format_str, *args, **kwargs) + except Exception as err: # pylint: disable=broad-except + error("log err! format_str: %s; args: %s; err: %s", format_str, str(args), repr(err)) + + +def info(format_str: str, *args, **kwargs): + """Log an info message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + try: + kwargs['stacklevel'] = _STACK_LEVEL + _current_logger.info(format_str, *args, **kwargs) + except Exception as err: # pylint: disable=broad-except + error("log err! format_str: %s; args: %s; err: %s", format_str, str(args), repr(err)) + + +def warning(format_str: str, *args, **kwargs): + """Log a warning message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + try: + kwargs['stacklevel'] = _STACK_LEVEL + _current_logger.warning(format_str, *args, **kwargs) + except Exception as err: # pylint: disable=broad-except + error("log err! format_str: %s; args: %s; err: %s", format_str, str(args), repr(err)) + + +def error(format_str: str, *args, **kwargs): + """Log an error message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + try: + kwargs['stacklevel'] = _STACK_LEVEL + _current_logger.error(format_str, *args, **kwargs) + except Exception as err: # pylint: disable=broad-except + # Fallback to print if logger fails + print(f"log err! format_str: {format_str}; args: {args}; err: {repr(err)}", file=sys.stderr) + + +def fatal(format_str: str, *args, **kwargs): + """Log a fatal message. + + Args: + format_str: Format string for the message + *args: Arguments for format string + **kwargs: Additional keyword arguments (may include 'extra' dict) + """ + try: + kwargs['stacklevel'] = _STACK_LEVEL + _current_logger.fatal(format_str, *args, **kwargs) + except Exception as err: # pylint: disable=broad-except + error("log err! format_str: %s; args: %s; err: %s", format_str, str(args), repr(err)) + + +def with_fields(**kwargs) -> BaseLogger: + """Create a logger with additional fields. + + Args: + **kwargs: Additional fields to include in logs + + Returns: + BaseLogger: Logger instance with additional fields + """ + return _current_logger.with_fields(**kwargs) + + +# Convenience function for backward compatibility +def set_default_logger(logger: BaseLogger): + """Set the default logger. + + Args: + logger: The logger instance to set as default + """ + set_logger(logger) diff --git a/trpc_agent_sdk/memory/__init__.py b/trpc_agent_sdk/memory/__init__.py new file mode 100644 index 000000000..cb78326ab --- /dev/null +++ b/trpc_agent_sdk/memory/__init__.py @@ -0,0 +1,36 @@ +# 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. +"""Memory management module. + +This module provides memory/RAG functionality including: +- Abstract memory service interfaces +- In-memory memory service implementation +""" + +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig + +from ._in_memory_memory_service import EventTtl +from ._in_memory_memory_service import InMemoryMemoryService +from ._redis_memory_service import RedisMemoryService +from ._sql_memory_service import MemStorageData +from ._sql_memory_service import MemStorageEvent +from ._sql_memory_service import SqlMemoryService +from ._utils import extract_words_lower +from ._utils import format_timestamp + +__all__ = [ + "BaseMemoryService", + "MemoryServiceConfig", + "EventTtl", + "InMemoryMemoryService", + "RedisMemoryService", + "MemStorageData", + "MemStorageEvent", + "SqlMemoryService", + "extract_words_lower", + "format_timestamp", +] diff --git a/trpc_agent_sdk/memory/_in_memory_memory_service.py b/trpc_agent_sdk/memory/_in_memory_memory_service.py new file mode 100644 index 000000000..eb2f8496b --- /dev/null +++ b/trpc_agent_sdk/memory/_in_memory_memory_service.py @@ -0,0 +1,214 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""An in-memory memory service for prototyping purpose only.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Optional +from typing_extensions import override + +from pydantic import BaseModel +from pydantic import Field + +from trpc_agent_sdk.abc import MemoryEntry +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.abc import SearchMemoryResponse +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Ttl + +from ._utils import extract_words_lower +from ._utils import format_timestamp + + +class EventTtl(BaseModel): + """Event TTL.""" + event: Event = Field(..., description="Event") + """Event""" + ttl: Ttl = Field(default_factory=Ttl) + """TTL configuration for the event.""" + + def is_expired(self, now: Optional[float] = None) -> bool: + """Check if the TTL is expired.""" + return self.ttl.is_expired(now) + + def update_expired_at(self) -> None: + """Calculate the expired time.""" + self.ttl.update_expired_at() + + +class InMemoryMemoryService(BaseMemoryService): + """An in-memory memory service for prototyping purpose only. + + This service stores events in memory with optional TTL and automatic cleanup. + It is suitable for development and testing, or for production with proper + TTL configuration to prevent memory growth. + Uses keyword matching instead of semantic search. + + Key features: + - Event TTL support for automatic expiration + - Periodic cleanup of expired events + - TTL is checked on access (search_memory) and storage (store_session) + """ + + def __init__(self, memory_service_config: Optional[MemoryServiceConfig] = None, enabled: bool = False): + super().__init__(memory_service_config=memory_service_config, enabled=enabled) + self._session_events: dict[str, dict[str, list[EventTtl]]] = {} + """Keys are app_name/user_id, session_id. Values are session event lists.""" + # Cleanup task + self.__cleanup_task: Optional[asyncio.Task] = None + self.__cleanup_stop_event: Optional[asyncio.Event] = None + # Start cleanup task if enabled + self._start_cleanup_task() + + @override + async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + if not isinstance(session, Session): + raise TypeError(f"Content must be a Session, got {type(session)}") + + self._session_events[session.save_key] = self._session_events.get(session.save_key, {}) + self._session_events[session.save_key][session.id] = [ + EventTtl(event=event, ttl=self._memory_service_config.ttl) for event in session.events + if event.content and event.content.parts + ] + + @override + async def search_memory(self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None) -> SearchMemoryResponse: + if key not in self._session_events: + return SearchMemoryResponse() + + words_in_query = extract_words_lower(query) + response = SearchMemoryResponse() + count = 0 + for session_events in self._session_events[key].values(): + for event_ttl in session_events: + if not event_ttl.event.content or not event_ttl.event.content.parts: + continue + words_in_event = extract_words_lower(' '.join( + [part.text for part in event_ttl.event.content.parts if part.text])) + if not words_in_event: + continue + + if any(query_word in words_in_event for query_word in words_in_query): + event_ttl.update_expired_at() + response.memories.append( + MemoryEntry( + content=event_ttl.event.content, + author=event_ttl.event.author, + timestamp=format_timestamp(event_ttl.event.timestamp), + )) + count += 1 + if limit > 0 and count >= limit: + return response + return response + + @override + async def close(self) -> None: + self._stop_cleanup_task() + await super().close() + + def _cleanup_expired(self) -> None: + """Remove all expired events. + + """ + removed_events: dict[str, dict[str, list[EventTtl]]] = {} + now = time.time() + # Clean expired events + for key, events in self._session_events.items(): + for session_id, event_list in events.items(): + for event_ttl in event_list: + if event_ttl.is_expired(now): + if key not in removed_events: + removed_events[key] = {} + if session_id not in removed_events[key]: + removed_events[key][session_id] = [] + removed_events[key][session_id].append(event_ttl) + # self._session_events[key][session_id].remove(event_ttl) + logger.info("Cleaned up expired event: %s/%s/%s", key, session_id, event_ttl.event.id) + for key, events in removed_events.items(): + for session_id, event_list in events.items(): + for event_ttl in event_list: + self._session_events[key][session_id].remove(event_ttl) + if not self._session_events[key][session_id]: + del self._session_events[key][session_id] + if not self._session_events[key]: + del self._session_events[key] + + async def _cleanup_loop(self) -> None: + """Background task for periodic cleanup of expired events.""" + logger.debug("Cleanup task started with interval: %ss", + self._memory_service_config.ttl.cleanup_interval_seconds) + + try: + while not self.__cleanup_stop_event.is_set(): + try: + # Wait for the cleanup interval or stop event + await asyncio.wait_for(self.__cleanup_stop_event.wait(), + timeout=self._memory_service_config.ttl.cleanup_interval_seconds) + # If we get here, stop event was set + break + except asyncio.TimeoutError: + # Timeout means it's time to cleanup + try: + self._cleanup_expired() + logger.debug("Cleanup cycle completed") + except Exception as ex: # pylint: disable=broad-except + logger.error("Error during cleanup: %s", ex, exc_info=True) + except Exception as ex: # pylint: disable=broad-except + logger.error("Cleanup loop encountered error: %s", ex, exc_info=True) + finally: + logger.debug("Cleanup task stopped") + + def _start_cleanup_task(self) -> None: + """Start the background cleanup task.""" + if not self._memory_service_config.ttl.need_ttl_expire(): + logger.debug("Cleanup task disabled (ttl is disabled)") + return + if self.__cleanup_task is not None: + logger.debug("Cleanup task is already running") + return + + self.__cleanup_stop_event = asyncio.Event() + self.__cleanup_task = asyncio.get_event_loop().create_task(self._cleanup_loop()) + logger.debug("Cleanup task created") + + def _stop_cleanup_task(self) -> None: + """Stop the background cleanup task.""" + if self.__cleanup_task is None: + return + if self.__cleanup_stop_event is not None: + self.__cleanup_stop_event.set() + if self.__cleanup_task and not self.__cleanup_task.done(): + self.__cleanup_task.cancel() + self.__cleanup_task = None + self.__cleanup_stop_event = None + logger.debug("Cleanup task stopped") diff --git a/trpc_agent_sdk/memory/_redis_memory_service.py b/trpc_agent_sdk/memory/_redis_memory_service.py new file mode 100644 index 000000000..0c51dc05a --- /dev/null +++ b/trpc_agent_sdk/memory/_redis_memory_service.py @@ -0,0 +1,116 @@ +# 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. +"""A Redis-based memory service for prototyping and multi-node sharing.""" + +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event as EventCls +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.storage import RedisCommand +from trpc_agent_sdk.storage import RedisCondition +from trpc_agent_sdk.storage import RedisExpire +from trpc_agent_sdk.storage import RedisStorage +from trpc_agent_sdk.types import MemoryEntry +from trpc_agent_sdk.types import SearchMemoryResponse + +from ._utils import extract_words_lower +from ._utils import format_timestamp + + +class RedisMemoryService(BaseMemoryService): + """A Redis-based memory service for prototyping and multi-node sharing. + + Uses keyword matching instead of semantic search. + Stores events in Redis as JSON. + """ + + def __init__( + self, + db_url: str, + enabled: bool = False, + is_async: bool = False, + memory_service_config: Optional[MemoryServiceConfig] = None, + **kwargs: Any, + ): + super().__init__(memory_service_config=memory_service_config, enabled=enabled) + # Redis needs default TTL configuration + self._redis_storage = RedisStorage(is_async=is_async, redis_url=db_url, **kwargs) + + @override + async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + # Store all events for the session in a Redis list as JSON + async with self._redis_storage.create_db_session() as redis_session: + key = f"memory:{session.save_key}:{session.id}" + events_json = [event.model_dump_json() for event in session.events if event.content and event.content.parts] + if events_json: + args = [key] + args.extend(events_json) + await self._redis_storage.delete(redis_session, key) # Remove old events + expire = RedisExpire(key=key, ttl=self._memory_service_config.ttl) + command = RedisCommand(method='rpush', args=tuple(args), expire=expire) + await self._redis_storage.execute_command(redis_session, command) + + @override + async def search_memory(self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None) -> SearchMemoryResponse: + response = SearchMemoryResponse() + async with self._redis_storage.create_db_session() as redis_session: + pattern = f"memory:{key}:*" + events_json_list = await self._redis_storage.query(redis_session, pattern, RedisCondition(limit=-1)) + # Extract words from query (handles both English and Chinese) + words_in_query = extract_words_lower(query) + count = 0 + for redis_key, event_json in events_json_list: + has_valid_event = False + event = None + + if not isinstance(event_json, list): + event_json = [event_json] + + for data in event_json: + try: + event = EventCls.model_validate_json(data) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error parsing event JSON: %s", ex) + continue + if not event or not event.content or not event.content.parts: + continue + words_in_event = extract_words_lower(' '.join( + [part.text for part in event.content.parts if part.text])) + if not words_in_event: + continue + if any(query_word in words_in_event for query_word in words_in_query): + response.memories.append( + MemoryEntry( + content=event.content, + author=event.author, + timestamp=format_timestamp(event.timestamp), + )) + count += 1 + has_valid_event = True + if limit > 0 and count >= limit: + break + # Refresh TTL on accessed keys that contain valid (non-expired) events + if has_valid_event: + expire = RedisExpire(key=redis_key, ttl=self._memory_service_config.ttl) + await self._redis_storage.expire(redis_session, expire) + if limit > 0 and count >= limit: + break + return response + + @override + async def close(self) -> None: + await self._redis_storage.close() + await super().close() diff --git a/trpc_agent_sdk/memory/_sql_memory_service.py b/trpc_agent_sdk/memory/_sql_memory_service.py new file mode 100644 index 000000000..a92e91c65 --- /dev/null +++ b/trpc_agent_sdk/memory/_sql_memory_service.py @@ -0,0 +1,342 @@ +# 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. +"""An in-memory memory service for prototyping purpose only.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime +from datetime import timedelta +from typing import Any +from typing import List +from typing import Optional +from typing_extensions import override + +from sqlalchemy import Boolean +from sqlalchemy import Text +from sqlalchemy import func +from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.types import String + +from trpc_agent_sdk.abc import MemoryEntry +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.abc import SearchMemoryResponse +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.storage import DEFAULT_MAX_KEY_LENGTH +from trpc_agent_sdk.storage import DEFAULT_MAX_VARCHAR_LENGTH +from trpc_agent_sdk.storage import DynamicJSON +from trpc_agent_sdk.storage import DynamicPickleType +from trpc_agent_sdk.storage import PreciseTimestamp +from trpc_agent_sdk.storage import SqlCondition +from trpc_agent_sdk.storage import SqlKey +from trpc_agent_sdk.storage import SqlStorage +from trpc_agent_sdk.storage import decode_content +from trpc_agent_sdk.storage import decode_grounding_metadata +from trpc_agent_sdk.storage import sanitize_content_json + +from ._utils import extract_words_lower +from ._utils import format_timestamp + + +class MemStorageData(DeclarativeBase): + """Base class for memory storage tables.""" + + pass + + +class MemStorageEvent(MemStorageData): + """Represents an event stored in the database.""" + __tablename__ = "mem_events" + + id: Mapped[str] = mapped_column(String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + save_key: Mapped[str] = mapped_column(String(DEFAULT_MAX_KEY_LENGTH), primary_key=True) + session_id: Mapped[str] = mapped_column(String(DEFAULT_MAX_KEY_LENGTH), + primary_key=True, + nullable=False, + default="") + + invocation_id: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH)) + author: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH)) + actions: Mapped[MutableDict[str, Any]] = mapped_column(DynamicPickleType) + long_running_tool_ids_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + branch: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True) + timestamp: Mapped[PreciseTimestamp] = mapped_column(PreciseTimestamp, default=func.now()) + + content: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=True) + grounding_metadata: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=True) + custom_metadata: Mapped[dict[str, Any]] = mapped_column(DynamicJSON, nullable=True) + + partial: Mapped[bool] = mapped_column(Boolean, nullable=True) + turn_complete: Mapped[bool] = mapped_column(Boolean, nullable=True) + error_code: Mapped[str] = mapped_column(String(DEFAULT_MAX_VARCHAR_LENGTH), nullable=True) + error_message: Mapped[str] = mapped_column(String(1024), nullable=True) + interrupted: Mapped[bool] = mapped_column(Boolean, nullable=True) + + @property + def long_running_tool_ids(self) -> set[str]: + return (set(json.loads(self.long_running_tool_ids_json)) if self.long_running_tool_ids_json else set()) + + @long_running_tool_ids.setter + def long_running_tool_ids(self, value: set[str]): + if value is None: + self.long_running_tool_ids_json = None + else: + self.long_running_tool_ids_json = json.dumps(list(value)) + + def update_event(self, session: Session, event: Event): + """update event from event""" + self.id = event.id + self.invocation_id = event.invocation_id + self.author = event.author + self.branch = event.branch + self.actions = event.actions + self.save_key = session.save_key + self.session_id = session.id + self.timestamp = datetime.fromtimestamp(event.timestamp) + self.long_running_tool_ids = event.long_running_tool_ids + self.partial = event.partial + self.turn_complete = event.turn_complete + self.error_code = event.error_code + self.error_message = event.error_message + self.interrupted = event.interrupted + if event.content: + self.content = sanitize_content_json(event.content.model_dump(exclude_none=True, mode="json")) + if event.grounding_metadata: + self.grounding_metadata = event.grounding_metadata.model_dump(exclude_none=True, mode="json") + if event.custom_metadata: + self.custom_metadata = event.custom_metadata + + @classmethod + def from_event(cls, session: Session, event: Event) -> MemStorageEvent: + storage_event = MemStorageEvent( + id=event.id, + invocation_id=event.invocation_id, + author=event.author, + branch=event.branch, + actions=event.actions, + save_key=session.save_key, + session_id=session.id, + timestamp=datetime.fromtimestamp(event.timestamp), + long_running_tool_ids=event.long_running_tool_ids, + partial=event.partial, + turn_complete=event.turn_complete, + error_code=event.error_code, + error_message=event.error_message, + interrupted=event.interrupted, + ) + if event.content: + storage_event.content = sanitize_content_json(event.content.model_dump(exclude_none=True, mode="json")) + if event.grounding_metadata: + storage_event.grounding_metadata = event.grounding_metadata.model_dump(exclude_none=True, mode="json") + if event.custom_metadata: + storage_event.custom_metadata = event.custom_metadata + return storage_event + + def to_event(self) -> Event: + return Event( + id=self.id, + invocation_id=self.invocation_id, + author=self.author, + branch=self.branch, + actions=self.actions, # type: ignore + timestamp=self.timestamp.timestamp(), + content=decode_content(sanitize_content_json(self.content)), + long_running_tool_ids=self.long_running_tool_ids, + partial=self.partial, + turn_complete=self.turn_complete, + error_code=self.error_code, + error_message=self.error_message, + interrupted=self.interrupted, + grounding_metadata=decode_grounding_metadata(self.grounding_metadata), + custom_metadata=self.custom_metadata, + ) + + +class SqlMemoryService(BaseMemoryService): + """An SQL-based memory service with TTL support for automatic expiration. + + Uses keyword matching instead of semantic search. + Stores events in SQL database with TTL support for automatic cleanup. + + Key features: + - Event TTL support for automatic expiration + - Periodic cleanup of expired events + - TTL is checked on access (search_memory) and storage (store_session) + """ + + def __init__(self, + db_url: str, + is_async: bool = False, + enabled: bool = False, + memory_service_config: Optional[MemoryServiceConfig] = None, + **kwargs: Any): + super().__init__(memory_service_config=memory_service_config, enabled=enabled) + self._sql_storage = SqlStorage(is_async=is_async, db_url=db_url, metadata=MemStorageData.metadata, **kwargs) + + self.__cleanup_task: Optional[asyncio.Task] = None + self.__cleanup_stop_event: Optional[asyncio.Event] = None + + self._start_cleanup_task() + + @override + async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + """Store a session in the memory. + + Only stores events that are not expired based on event_ttl_seconds. + """ + async with self._sql_storage.create_db_session() as sql_session: + is_exist = False + for event in session.events: + if not event.content or not event.content.parts: + continue + content = sanitize_content_json(event.content.model_dump(exclude_none=True, mode="json")) + if content: + is_exist = True + # Check if the event already exists + event_key = SqlKey(key=(event.id, session.save_key, session.id), storage_cls=MemStorageEvent) + storage_event: Optional[MemStorageEvent] = await self._sql_storage.get(sql_session, event_key) + if storage_event: + storage_event.update_event(session, event) + else: + await self._sql_storage.add(sql_session, MemStorageEvent.from_event(session, event)) + + if is_exist: + await self._sql_storage.commit(sql_session) + + @override + async def search_memory(self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None) -> SearchMemoryResponse: + """Search the memory for a query. + + Only returns events that are not expired based on event_ttl_seconds. + """ + words_in_query = extract_words_lower(query) + response = SearchMemoryResponse() + async with self._sql_storage.create_db_session() as sql_session: + filters = [MemStorageEvent.save_key == key] + order_func = MemStorageEvent.timestamp.desc + conditions = SqlCondition(filters=filters, order_func=order_func, limit=limit) + event_key = SqlKey(key=(key, ), storage_cls=MemStorageEvent) + storage_events: List[MemStorageEvent] = await self._sql_storage.query(sql_session, event_key, conditions) + if not storage_events: + return response + + count = 0 + for storage_event in storage_events: + event = storage_event.to_event() + if not event.content or not event.content.parts: + continue + words_in_event = extract_words_lower(' '.join([part.text for part in event.content.parts if part.text])) + if not words_in_event: + continue + if any(query_word in words_in_event for query_word in words_in_query): + count += 1 + storage_event.timestamp = func.now() + response.memories.append( + MemoryEntry( + content=event.content, + author=event.author, + timestamp=format_timestamp(event.timestamp), + )) + if count: + await self._sql_storage.commit(sql_session) + return response + + @override + async def close(self) -> None: + """Close the service and release resources.""" + self._stop_cleanup_task() + if self._sql_storage: + await self._sql_storage.close() + await super().close() + + async def _cleanup_expired_async(self) -> None: + """Async version of cleanup that deletes expired events from database. + + Uses SQL-level batch deletion for optimal performance. + Deletes all expired events in a single SQL DELETE statement. + """ + async with self._sql_storage.create_db_session() as sql_session: + # Calculate expiration threshold using database local time + expire_before = datetime.now() - timedelta(seconds=self._memory_service_config.ttl.ttl_seconds) + + # Count events before deletion (optional, for logging) + count_key = SqlKey(key=tuple(), storage_cls=MemStorageEvent) + count_filters = [MemStorageEvent.timestamp < expire_before] + count_conditions = SqlCondition(filters=count_filters) + expired_events = await self._sql_storage.query(sql_session, count_key, count_conditions) + deleted_count = len(expired_events) if expired_events else 0 + + if deleted_count > 0: + # Batch delete all expired events in a single SQL statement + delete_key = SqlKey(key=tuple(), storage_cls=MemStorageEvent) + delete_filters = [MemStorageEvent.timestamp < expire_before] + delete_conditions = SqlCondition(filters=delete_filters) + await self._sql_storage.delete(sql_session, delete_key, delete_conditions) + await self._sql_storage.commit(sql_session) + logger.info("Memory cleanup completed: deleted %s expired events", deleted_count) + + async def _cleanup_loop(self) -> None: + """Background task for periodic cleanup of expired events.""" + logger.debug("Memory cleanup task started with interval: %ss", + self._memory_service_config.ttl.cleanup_interval_seconds) + + try: + while not self.__cleanup_stop_event.is_set(): + try: + await asyncio.wait_for(self.__cleanup_stop_event.wait(), + timeout=self._memory_service_config.ttl.cleanup_interval_seconds) + break + except asyncio.TimeoutError: + try: + await self._cleanup_expired_async() + logger.debug("Memory cleanup cycle completed") + except Exception as ex: # pylint: disable=broad-except + logger.error("Error during memory cleanup: %s", ex, exc_info=True) + except Exception as ex: # pylint: disable=broad-except + logger.error("Memory cleanup loop encountered error: %s", ex, exc_info=True) + finally: + logger.debug("Memory cleanup task stopped") + + def _start_cleanup_task(self) -> None: + """Start the background cleanup task.""" + if not self._memory_service_config.ttl.need_ttl_expire(): + logger.debug("Memory cleanup task disabled (ttl is disabled)") + return + + if self.__cleanup_task is not None: + logger.debug("Memory cleanup task is already running") + return + + self.__cleanup_stop_event = asyncio.Event() + self.__cleanup_task = asyncio.get_event_loop().create_task(self._cleanup_loop()) + logger.debug("Memory cleanup task created") + + def _stop_cleanup_task(self) -> None: + """Stop the background cleanup task.""" + if self.__cleanup_task is None: + return + + if self.__cleanup_stop_event is not None: + self.__cleanup_stop_event.set() + + if self.__cleanup_task and not self.__cleanup_task.done(): + self.__cleanup_task.cancel() + + self.__cleanup_task = None + self.__cleanup_stop_event = None + logger.debug("Memory cleanup task stopped") diff --git a/trpc_agent_sdk/memory/_utils.py b/trpc_agent_sdk/memory/_utils.py new file mode 100644 index 000000000..dfe98ae87 --- /dev/null +++ b/trpc_agent_sdk/memory/_utils.py @@ -0,0 +1,44 @@ +# 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. +"""Utility functions for memory service.""" +import re +from datetime import datetime +from trpc_agent_sdk.events import Event + + +def format_timestamp(timestamp: float) -> str: + """Format the timestamp of the memory entry.""" + return datetime.fromtimestamp(timestamp).isoformat() + + +def extract_words_lower(text: str) -> set[str]: + """Extract words from a string and convert them to lowercase. + + Extracts both English words and Chinese characters. + For English: extracts words (sequences of letters) + For Chinese: extracts individual characters (Unicode range \u4e00-\u9fff) + """ + words = set() + # Extract English words + words.update([word.lower() for word in re.findall(r'[A-Za-z]+', text)]) + # Extract Chinese characters + words.update(re.findall(r'[\u4e00-\u9fff]', text)) + return words + + +def event_to_text(event: Event) -> str: + """Extract text from event content parts. + + Args: + event: The event to extract text from. + + Returns: + The text from the event content parts. + """ + if not event.content or not event.content.parts: + return "" + parts = [part.text for part in event.content.parts if part.text] + return " ".join(parts).strip() diff --git a/trpc_agent_sdk/memory/mem0_memory_service.py b/trpc_agent_sdk/memory/mem0_memory_service.py new file mode 100644 index 000000000..3507af84c --- /dev/null +++ b/trpc_agent_sdk/memory/mem0_memory_service.py @@ -0,0 +1,438 @@ +# 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. +"""Mem0-based memory service for multi-session memory storage/search.""" + +import asyncio +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from typing import Callable +from typing import Optional +from typing import Set +from typing import Union +from typing_extensions import override + +import httpx +from mem0 import AsyncMemory +from mem0 import AsyncMemoryClient + +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event as EventCls +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import MemoryEntry +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import SearchMemoryResponse + +from ._utils import event_to_text + +_MEM0_KEY_METADATA = "metadata" + + +@dataclass +class Mem0Kwargs: + user_id: str + agent_id: Optional[str] = None + run_id: Optional[str] = None, + session_id: Optional[str] = None, + filters: Optional[dict[str, Any]] = None, + + +def set_mem0_filters(agent_context: AgentContext, filters: dict[str, Any]) -> None: + """Set mem0 metadata/filters into agent_context.""" + if agent_context: + agent_context.with_metadata(_MEM0_KEY_METADATA, filters) + + +def get_mem0_filters(agent_context: Optional[AgentContext] = None) -> dict[str, Any]: + """Get mem0 metadata/filters from agent_context.""" + filters: dict[str, Any] = {} + if agent_context: + filters.update(agent_context.get_metadata(_MEM0_KEY_METADATA, {})) + return filters + + +class Mem0MemoryService(BaseMemoryService): + """Mem0-based memory service with two-level key strategy. + + Two-level key: + - level 1 (primary): session.save_key -> mapped to mem0 user_id + - level 2 (sub-key): session.id -> stored in metadata["session_id"] + + This enables: + - query all sessions by session.save_key + - query one specific session by adding metadata filter session_id + + When TTL is configured, a background cleanup task periodically iterates over + all known user_ids, retrieves their memories via get_all(), and deletes entries + whose stored event.timestamp is older than ttl_seconds. + """ + + def __init__( + self, + memory_service_config: MemoryServiceConfig, + mem0_client: Union[AsyncMemoryClient, AsyncMemory], + infer: bool = True, + async_mode: bool = False, + ) -> None: + super().__init__(memory_service_config=memory_service_config) + self._mem0 = mem0_client + self._infer = infer + # only for AsyncMemoryClient, when async_mode is True, + # the platform will wait for the indexing to complete before returning + self._async_mode = async_mode + self._known_user_ids: Set[tuple[str, str]] = set() + + self.__cleanup_task: Optional[asyncio.Task] = None + self.__cleanup_stop_event: Optional[asyncio.Event] = None + self._is_remote_mem0 = isinstance(self._mem0, AsyncMemoryClient) + + self._start_cleanup_task() + + def parse_mem0_kwargs(self, metadata: dict[str, Any], save_key: str) -> Mem0Kwargs: + """Parse mem0 kwargs from save_key.""" + agent_id = None + session_id = metadata.pop("session_id", None) + run_id = session_id + keys = save_key.split("/") + if len(keys) >= 2: + agent_id = keys[0] + user_id = "".join(keys[1:]) + else: + user_id = save_key + if self._is_remote_mem0: + return Mem0Kwargs(user_id=user_id, agent_id=agent_id, run_id=session_id, filters=metadata or {}) + return Mem0Kwargs(user_id=user_id, + agent_id=agent_id, + run_id=run_id, + session_id=session_id, + filters=metadata or {}) + + async def __mem0_store_session(self, messages: list[dict[str, str]], mem0_kwargs: Mem0Kwargs) -> None: + """Add messages to Mem0.""" + if self._is_remote_mem0: + return await self._mem0.add(messages, + user_id=mem0_kwargs.user_id, + agent_id=mem0_kwargs.agent_id, + run_id=mem0_kwargs.run_id, + metadata=mem0_kwargs.filters, + infer=self._infer, + async_mode=self._async_mode) + if not self._infer: + # infer=False: upsert event-by-event to avoid duplicates. + await self._mem0.delete_all(user_id=mem0_kwargs.user_id, + agent_id=mem0_kwargs.agent_id, + run_id=mem0_kwargs.run_id) + if self._infer and not self._is_remote_mem0: + mem0_kwargs.run_id = None + mem0_kwargs.agent_id = None + return await self._mem0.add(messages, + user_id=mem0_kwargs.user_id, + agent_id=mem0_kwargs.agent_id, + run_id=mem0_kwargs.run_id, + metadata=mem0_kwargs.filters, + infer=self._infer) + + @override + async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + """Store session into Mem0 using two-level key. + + level-1 key: session.save_key -> user_id + level-2 key: session.id -> metadata["session_id"] + """ + valid_events = [event for event in session.events if event.content and event.content.parts] + if not valid_events: + return + + session_id = session.id + mem0_metadata = get_mem0_filters(agent_context) + mem0_metadata["session_id"] = session_id + mem0_kwargs = self.parse_mem0_kwargs(mem0_metadata, session.save_key) + + self._known_user_ids.add((mem0_kwargs.agent_id, mem0_kwargs.user_id)) + # infer=True is not "each message is stored as is" mode + # Mem0 official documentation: infer=True will do information extraction + conflict + # resolution (latest truth wins), and may do deduplication/update, instead of storing each message as is. + # So "some rounds are not stored" is expected behavior. + user_messages = [] + assistant_messages = [] + for event in valid_events: + text = event_to_text(event) + if not text: + continue + role = self._event_to_role(event) + if role == "user": + user_messages.append({"role": role, "content": text}) + else: + assistant_messages.append({"role": role, "content": text}) + try: + if user_messages: + mem0_kwargs.filters["real_role"] = "user" + await self.__mem0_store_session(user_messages, mem0_kwargs) + if assistant_messages: + mem0_kwargs.filters["real_role"] = "assistant" + await self.__mem0_store_session(assistant_messages, mem0_kwargs) + except Exception as e: # pylint: disable=broad-except + logger.warning("Failed to store session in Mem0. save_key=%s, " + "session_id=%s, err=%s", session.save_key, session.id, e) + + async def __mem0_search_memory(self, query: str, mem0_kwargs: Mem0Kwargs, limit: int) -> SearchMemoryResponse: + """Search memory by primary key(session.save_key) + optional metadata filters.""" + if self._is_remote_mem0: + api_filters: list[dict[str, Any]] = [{"user_id": mem0_kwargs.user_id}] + if mem0_kwargs.agent_id: + api_filters.append({"agent_id": mem0_kwargs.agent_id}) + if mem0_kwargs.filters: + for key, value in mem0_kwargs.filters.items(): + api_filters.append({key: value}) + filters = {"AND": [{"OR": api_filters}, {"run_id": "*"}]} + + async def search_fn(): + return await self._mem0.search( + query=query, + user_id=mem0_kwargs.user_id, + filters=filters, + top_k=limit, + ) + + return await self._retry_transport("search", search_fn) + kwargs: dict[str, Any] = {"user_id": mem0_kwargs.user_id, "limit": limit} + if mem0_kwargs.agent_id: + kwargs["agent_id"] = mem0_kwargs.agent_id + if mem0_kwargs.filters: + kwargs["filters"] = mem0_kwargs.filters or None + if self._infer: + kwargs["run_id"] = None + kwargs["agent_id"] = None + return await self._mem0.search(query=query, **kwargs) + + @override + async def search_memory( + self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None, + ) -> SearchMemoryResponse: + """Search memory by primary key(session.save_key) + optional metadata filters.""" + response = SearchMemoryResponse() + metadata = get_mem0_filters(agent_context) + mem0_kwargs = self.parse_mem0_kwargs(metadata, key) + try: + search_result: dict = await self.__mem0_search_memory(query, mem0_kwargs, limit) + results: list[dict[str, Any]] = search_result.get("results", []) + for item in results: + memory_text = item.get("memory") + if not memory_text: + continue + # Timestamp is stored as formatted ISO string in event.timestamp metadata key. + created_at = item.get("created_at", datetime.now().isoformat()) + updated_at = item.get("updated_at", None) or created_at + if self._is_remote_mem0: + role = item.get("metadata", {}).get("real_role", "user") + else: + role = item.get("role") or item.get("real_role") + entry = MemoryEntry( + content=Content(parts=[Part.from_text(text=memory_text)], role=role), + author=role, + timestamp=updated_at, + ) + response.memories.append(entry) + except Exception as e: # pylint: disable=broad-except + resp_body = "" + if isinstance(e, httpx.HTTPStatusError): + try: + resp_body = f", response_body={e.response.text!r}" + except Exception: # pylint: disable=broad-except + pass + logger.warning("Failed to search memory in Mem0. key=%s, query=%s, err=%s%s", key, query, e, resp_body) + return response + + @override + async def close(self) -> None: + """Stop cleanup task and close underlying async client if present.""" + self._stop_cleanup_task() + if hasattr(self._mem0, "async_client"): + try: + await self._mem0.async_client.aclose() + except Exception: # pylint: disable=broad-except + pass + + async def _retry_transport(self, op_name: str, call: Callable[..., Any], max_attempts: int = 5) -> Any: + """Retry mem0 API calls for transient network/TLS transport failures.""" + last_exc: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + return await call() + except httpx.TransportError as exc: + last_exc = exc + if attempt == max_attempts: + break + wait_s = min(2 * attempt, 8) + logger.warning("[net-retry] %s attempt=%s/%s failed: %s; sleep %ss", op_name, attempt, max_attempts, + exc, wait_s) + await asyncio.sleep(wait_s) + logger.warning("[net-retry] %s exhausted retries: %s", op_name, last_exc) + return None + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _event_to_role(event: EventCls) -> str: + """Map framework event author to Mem0 role.""" + return "user" if event.author == "user" else "assistant" + + @staticmethod + def _parse_event_timestamp(ts_str: str) -> Optional[float]: + """Parse a stored event.timestamp ISO string back to a Unix float. + + Returns None when the value cannot be parsed. + """ + try: + return datetime.fromisoformat(ts_str).timestamp() + except (ValueError, TypeError): + return None + + @staticmethod + def _extract_memories_from_result(result: Any) -> list[dict[str, Any]]: + """Normalise the return value of get_all() / search() to a plain list.""" + if isinstance(result, list): + return result + if isinstance(result, dict): + return result.get("results", []) + return [] + + # ------------------------------------------------------------------ + # TTL eviction + # ------------------------------------------------------------------ + + def _start_cleanup_task(self) -> None: + """Start the background TTL cleanup task if TTL is configured.""" + if not self._memory_service_config.ttl.need_ttl_expire(): + logger.debug("Mem0 memory cleanup task disabled (ttl is disabled)") + return + + if self.__cleanup_task is not None: + logger.debug("Mem0 memory cleanup task is already running") + return + + self.__cleanup_stop_event = asyncio.Event() + self.__cleanup_task = asyncio.get_event_loop().create_task(self._cleanup_loop()) + logger.debug("Mem0 memory cleanup task created") + + def _stop_cleanup_task(self) -> None: + """Stop the background TTL cleanup task.""" + if self.__cleanup_task is None: + return + + if self.__cleanup_stop_event is not None: + self.__cleanup_stop_event.set() + + if not self.__cleanup_task.done(): + self.__cleanup_task.cancel() + + self.__cleanup_task = None + self.__cleanup_stop_event = None + logger.debug("Mem0 memory cleanup task stopped") + + async def _cleanup_loop(self) -> None: + """Periodic background loop that evicts expired memories.""" + logger.debug("Mem0 memory cleanup task started with interval: %ss", + self._memory_service_config.ttl.cleanup_interval_seconds) + try: + while not self.__cleanup_stop_event.is_set(): + try: + await asyncio.wait_for( + self.__cleanup_stop_event.wait(), + timeout=self._memory_service_config.ttl.cleanup_interval_seconds, + ) + break + except asyncio.TimeoutError: + try: + await self._cleanup_expired_memories() + logger.debug("Mem0 memory cleanup cycle completed") + except Exception as e: # pylint: disable=broad-except + logger.error("Error during Mem0 memory cleanup: %s", e, exc_info=True) + except Exception as e: # pylint: disable=broad-except + logger.error("Mem0 memory cleanup loop encountered error: %s", e, exc_info=True) + finally: + logger.debug("Mem0 memory cleanup task stopped") + + async def _cleanup_expired_memories(self) -> None: + """Iterate over all known user_ids and delete expired memories. + + A memory is considered expired when the ISO timestamp stored under the + ``event.timestamp`` metadata key represents a point in time older than + ``ttl_seconds`` ago. + + Both AsyncMemoryClient and AsyncMemory expose get_all(user_id) and + delete(memory_id) with the same signature, so no branching is needed here. + """ + if not self._known_user_ids: + return + + now = time.time() + ttl_seconds = self._memory_service_config.ttl.ttl_seconds + + for ids in list(self._known_user_ids): + agent_id, user_id = ids + try: + if not self._is_remote_mem0: + if self._infer: + agent_id = None + result = await self._mem0.get_all(user_id=user_id, agent_id=agent_id) + else: + # AsyncMemoryClient(v2) requires filters and role-scoped data + # may live under user_id or agent_id, so use OR instead of AND. + filters = { + "AND": [{ + "OR": [ + { + "user_id": user_id + }, + { + "agent_id": agent_id + }, + ] + }, { + "run_id": "*" + }] + } + result = await self._retry_transport( + "get_all", + lambda: self._mem0.get_all(filters=filters), + ) + + memories = self._extract_memories_from_result(result) + deleted_count = 0 + for memory in memories: + created_at = memory.get("created_at", datetime.now().isoformat()) + ts_str = memory.get("updated_at", None) or created_at + ts = self._parse_event_timestamp(ts_str) + if ts is None: + continue + if ts < now - ttl_seconds: + memory_id = memory.get("id") + if memory_id: + await self._mem0.delete(memory_id=memory_id) + deleted_count += 1 + + if deleted_count: + logger.info("Mem0 cleanup: deleted %s expired memories for user_id=%s", deleted_count, user_id) + except Exception as e: # pylint: disable=broad-except + resp_body = "" + if isinstance(e, httpx.HTTPStatusError): + try: + resp_body = f", response_body={e.response.text!r}" + except Exception: # pylint: disable=broad-except + pass + logger.warning("Mem0 cleanup failed for user_id=%s, err=%s%s", user_id, e, resp_body) diff --git a/trpc_agent_sdk/memory/mempalace_memory_service.py b/trpc_agent_sdk/memory/mempalace_memory_service.py new file mode 100644 index 000000000..032f1efcc --- /dev/null +++ b/trpc_agent_sdk/memory/mempalace_memory_service.py @@ -0,0 +1,441 @@ +# 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. +"""MemPalace-based memory service for local-first semantic memory.""" + +from __future__ import annotations + +import asyncio +import hashlib +import re +import time +from datetime import datetime +from typing import Any +from typing import Optional +from typing_extensions import override + +from mempalace.config import MempalaceConfig # type: ignore[import-not-found] +from mempalace.config import sanitize_content # type: ignore[import-not-found] +from mempalace.config import sanitize_name # type: ignore[import-not-found] +from mempalace.palace import get_collection # type: ignore[import-not-found] +from mempalace.searcher import search_memories # type: ignore[import-not-found] + +from trpc_agent_sdk.abc import MemoryEntry +from trpc_agent_sdk.abc import MemoryServiceABC as BaseMemoryService +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import SearchMemoryResponse + +from ._utils import format_timestamp +from ._utils import event_to_text + +_MEMPALACE_KEY_METADATA = "mempalace_metadata" +_DEFAULT_ROOM = "conversations" +_DEFAULT_WING = "trpc_agent" +_DEFAULT_ADDED_BY = "trpc_agent" +_EVENT_TEXT_PREFIX_RE = re.compile(r"^\[([^\]]+)\]\s+([^:\n]+):\n") + +__all__ = [ + "MempalaceMemoryService", + "get_mempalace_filters", + "set_mempalace_filters", +] + + +def set_mempalace_filters(agent_context: AgentContext, filters: dict[str, Any]) -> None: + """Set MemPalace wing/room filters into agent_context.""" + if agent_context: + agent_context.with_metadata(_MEMPALACE_KEY_METADATA, filters) + + +def get_mempalace_filters(agent_context: Optional[AgentContext] = None) -> dict[str, Any]: + """Get MemPalace wing/room filters from agent_context.""" + filters: dict[str, Any] = {} + if agent_context: + filters.update(agent_context.get_metadata(_MEMPALACE_KEY_METADATA, {})) + return filters + + +def _slugify_name(value: str, default: str) -> str: + """Convert arbitrary framework keys into MemPalace-safe wing/room names.""" + value = (value or "").strip().lower() + value = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff .'-]+", "_", value) + value = re.sub(r"[_\s-]+", "_", value).strip("_. -'") + return value[:128] or default + + +class MempalaceMemoryService(BaseMemoryService): + """MemPalace-backed memory service. + + This implementation stores framework events as verbatim MemPalace drawers and + searches them with MemPalace semantic search. MemPalace is an optional + dependency; install it with ``pip install mempalace`` or the project extra. + """ + + def __init__( + self, + memory_service_config: Optional[MemoryServiceConfig] = None, + config: Optional[MempalaceConfig] = None, + wing: Optional[str] = None, + room: str = _DEFAULT_ROOM, + added_by: str = _DEFAULT_ADDED_BY, + **kwargs: Any, + ) -> None: + super().__init__(memory_service_config=memory_service_config) + self._config = config or MempalaceConfig() + self._wing = wing + self._room = room + self._added_by = added_by + self._pending_tasks: set[asyncio.Task[None]] = set() + self._scheduled_drawer_ids: set[str] = set() + self._stored_drawer_ids: set[str] = set() + self.__cleanup_task: Optional[asyncio.Task] = None + self.__cleanup_stop_event: Optional[asyncio.Event] = None + self._start_cleanup_task() + + @override + async def store_session(self, session: Session, agent_context: Optional[AgentContext] = None) -> None: + """Store session events as verbatim MemPalace drawers.""" + filters = get_mempalace_filters(agent_context) + wing = self._resolve_wing(session.save_key, filters) + room = self._resolve_room(filters) + + events_to_store: list[tuple[Event, str, str]] = [] + for event in session.events: + text = self._event_to_text(event) + if not text: + continue + drawer_id = self._drawer_id(wing, room, event.id, text) + if drawer_id in self._stored_drawer_ids or drawer_id in self._scheduled_drawer_ids: + continue + self._scheduled_drawer_ids.add(drawer_id) + events_to_store.append((event, text, drawer_id)) + if not events_to_store: + return + + task = asyncio.create_task(self._store_events_background(session, events_to_store, wing, room)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) + + @override + async def search_memory( + self, + key: str, + query: str, + limit: int = 10, + agent_context: Optional[AgentContext] = None, + ) -> SearchMemoryResponse: + """Search MemPalace by primary framework key plus optional filters.""" + response = SearchMemoryResponse() + filters = get_mempalace_filters(agent_context) + wing = self._resolve_wing(key, filters) + room = filters.get("room", None) + + search_result = await self._search(query, wing, room, limit) + for item in search_result.get("results", []): + memory_text = item.get("text") + if not memory_text: + continue + metadata = item.get("metadata") or item + response.memories.append( + MemoryEntry( + content=Content(parts=[Part.from_text(text=memory_text)], role="user"), + author=self._memory_author(metadata, memory_text), + timestamp=self._memory_timestamp(metadata, memory_text), + )) + return response + + @override + async def close(self) -> None: + """Stop cleanup task and wait for pending background writes.""" + self._stop_cleanup_task() + await self._wait_pending_writes() + + async def _wait_pending_writes(self) -> None: + """Wait for pending background writes.""" + if self._pending_tasks: + await asyncio.gather(*self._pending_tasks, return_exceptions=True) + + async def delete_memory(self, wing: str, room: Optional[str] = None) -> int: + """Delete MemPalace drawers by wing, optionally limited to a room. + + Args: + wing: Wing to delete. For this service, this is usually the + slugified save_key, i.e. ``{app}/{user}``. + room: Optional room under the wing. If omitted, the whole wing is + deleted. + + Returns: + Number of matching drawers found before deletion. + """ + await self._wait_pending_writes() + try: + deleted_count = await asyncio.to_thread(self._delete_memory, wing, room) + except Exception as exc: # pylint: disable=broad-except + logger.warning("Failed to delete MemPalace memory. wing=%s, room=%s, err=%s", wing, room, exc) + return 0 + + # Deleting from storage invalidates the in-process dedupe cache. Keep it + # conservative so deleted events can be written again later if needed. + self._stored_drawer_ids.clear() + self._scheduled_drawer_ids.clear() + return deleted_count + + # ------------------------------------------------------------------ + # TTL eviction + # ------------------------------------------------------------------ + + def _start_cleanup_task(self) -> None: + """Start the background TTL cleanup task if TTL is configured.""" + if not self._memory_service_config.ttl.need_ttl_expire(): + logger.debug("MemPalace memory cleanup task disabled (ttl is disabled)") + return + + if self.__cleanup_task is not None: + logger.debug("MemPalace memory cleanup task is already running") + return + + self.__cleanup_stop_event = asyncio.Event() + self.__cleanup_task = asyncio.create_task(self._cleanup_loop()) + logger.debug("MemPalace memory cleanup task created") + + def _stop_cleanup_task(self) -> None: + """Stop the background TTL cleanup task.""" + if self.__cleanup_task is None: + return + + if self.__cleanup_stop_event is not None: + self.__cleanup_stop_event.set() + + if not self.__cleanup_task.done(): + self.__cleanup_task.cancel() + + self.__cleanup_task = None + self.__cleanup_stop_event = None + logger.debug("MemPalace memory cleanup task stopped") + + async def _cleanup_loop(self) -> None: + """Periodic background loop that evicts expired memories.""" + logger.debug("MemPalace memory cleanup task started with interval: %ss", + self._memory_service_config.ttl.cleanup_interval_seconds) + try: + while not self.__cleanup_stop_event.is_set(): + try: + await asyncio.wait_for( + self.__cleanup_stop_event.wait(), + timeout=self._memory_service_config.ttl.cleanup_interval_seconds, + ) + break + except asyncio.TimeoutError: + try: + await self._cleanup_expired_memories() + logger.debug("MemPalace memory cleanup cycle completed") + except Exception as exc: # pylint: disable=broad-except + logger.error("Error during MemPalace memory cleanup: %s", exc, exc_info=True) + except Exception as exc: # pylint: disable=broad-except + logger.error("MemPalace memory cleanup loop encountered error: %s", exc, exc_info=True) + finally: + logger.debug("MemPalace memory cleanup task stopped") + + async def _cleanup_expired_memories(self) -> None: + """Delete MemPalace drawers whose event timestamp has expired.""" + await self._wait_pending_writes() + deleted_ids = await asyncio.to_thread(self._cleanup_expired_memories_sync) + if deleted_ids: + self._stored_drawer_ids.difference_update(deleted_ids) + logger.info("MemPalace cleanup: deleted %s expired memories", len(deleted_ids)) + + async def _store_events_background( + self, + session: Session, + events_to_store: list[tuple[Event, str, str]], + wing: str, + room: str, + ) -> None: + """Store MemPalace drawers without blocking the caller.""" + drawer_ids = {drawer_id for _, _, drawer_id in events_to_store} + stored_drawer_ids: set[str] = set() + try: + stored_drawer_ids = await asyncio.to_thread(self._store_events, session, events_to_store, wing, room) + except Exception as exc: # pylint: disable=broad-except + logger.warning("Failed to store session in MemPalace. save_key=%s, session_id=%s, err=%s", session.save_key, + session.id, exc) + finally: + self._scheduled_drawer_ids.difference_update(drawer_ids) + self._stored_drawer_ids.update(stored_drawer_ids) + + def _store_events( + self, + session: Session, + events_to_store: list[tuple[Event, str, str]], + wing: str, + room: str, + ) -> set[str]: + """Synchronous MemPalace drawer upsert.""" + + collection_name = self._config.collection_name + col = get_collection(self._config.palace_path, collection_name=collection_name, create=True) + + stored_drawer_ids: set[str] = set() + safe_wing = sanitize_name(wing, "wing") + safe_room = sanitize_name(room, "room") + for event, text, drawer_id in events_to_store: + content = sanitize_content(text) + source_file = f"{session.save_key}/{session.id}/{event.id}" + metadata = { + "wing": safe_wing, + "room": safe_room, + "source_file": source_file, + "session_id": session.id, + "event_id": event.id, + "invocation_id": event.invocation_id, + "author": event.author, + "timestamp": format_timestamp(event.timestamp), + "added_by": self._added_by, + "filed_at": datetime.now().isoformat(), + "chunk_index": 0, + } + try: + existing = col.get(ids=[drawer_id]) + if existing and existing.get("ids"): + stored_drawer_ids.add(drawer_id) + continue + col.upsert(ids=[drawer_id], documents=[content], metadatas=[metadata]) + stored_drawer_ids.add(drawer_id) + except Exception as exc: # pylint: disable=broad-except + logger.warning("Failed to store MemPalace drawer. drawer_id=%s, err=%s", drawer_id, exc) + return stored_drawer_ids + + async def _search(self, query: str, wing: str, room: Optional[str], limit: int) -> dict[str, list[dict[str, Any]]]: + """Synchronous MemPalace semantic search.""" + try: + return await asyncio.to_thread( + search_memories, + query=query, + palace_path=self._config.palace_path, + wing=wing, + room=room, + n_results=limit, + ) + except Exception as exc: # pylint: disable=broad-except + logger.warning("Failed to search MemPalace. query=%s, wing=%s, room=%s, err=%s", query, wing, room, exc) + return {"results": []} + + def _delete_memory(self, wing: str, room: Optional[str] = None) -> int: + """Synchronously delete MemPalace drawers by wing/room.""" + safe_wing = sanitize_name(_slugify_name(wing, _DEFAULT_WING), "wing") + if room is None: + where: dict[str, Any] = {"wing": safe_wing} + else: + safe_room = sanitize_name(_slugify_name(room, _DEFAULT_ROOM), "room") + where = {"$and": [{"wing": safe_wing}, {"room": safe_room}]} + + col = get_collection(self._config.palace_path, collection_name=self._config.collection_name, create=False) + existing = col.get(where=where) + ids = existing.get("ids", []) if existing else [] + if ids: + col.delete(where=where) + return len(ids) + + def _cleanup_expired_memories_sync(self) -> set[str]: + """Synchronously delete expired MemPalace drawers written by this service.""" + now = time.time() + ttl_seconds = self._memory_service_config.ttl.ttl_seconds + col = get_collection(self._config.palace_path, collection_name=self._config.collection_name, create=False) + + expired_ids: list[str] = [] + offset = 0 + batch_size = 500 + while True: + batch = col.get( + where={"added_by": self._added_by}, + include=["metadatas"], + limit=batch_size, + offset=offset, + ) + ids = batch.get("ids", []) if batch else [] + metadatas = batch.get("metadatas", []) if batch else [] + if not ids: + break + + for drawer_id, metadata in zip(ids, metadatas): + metadata = metadata or {} + ts = self._parse_memory_timestamp(metadata.get("timestamp")) + if ts is not None and ts < now - ttl_seconds: + expired_ids.append(drawer_id) + + if len(ids) < batch_size: + break + offset += len(ids) + + for index in range(0, len(expired_ids), batch_size): + col.delete(ids=expired_ids[index:index + batch_size]) + + return set(expired_ids) + + def _resolve_wing(self, key: str, filters: dict[str, Any]) -> str: + return _slugify_name(filters.get("wing", self._wing or key), _DEFAULT_WING) + + def _resolve_room(self, filters: dict[str, Any]) -> str: + return _slugify_name(filters.get("room", self._room), _DEFAULT_ROOM) + + @staticmethod + def _drawer_id(wing: str, room: str, event_id: str, content: str) -> str: + digest = hashlib.sha256(f"{wing}|{room}|{event_id}|{content}".encode()).hexdigest()[:24] + return f"drawer_{wing}_{room}_{digest}" + + @staticmethod + def _memory_author(metadata: dict[str, Any], memory_text: str = "") -> str: + """Return a framework-friendly author for a retrieved memory.""" + author = metadata.get("author") + if isinstance(author, str) and author.strip(): + return author.strip() + role = metadata.get("role") + if isinstance(role, str) and role.strip(): + return role.strip() + match = _EVENT_TEXT_PREFIX_RE.match(memory_text) + if match: + return match.group(2).strip() + return "user" + + @staticmethod + def _memory_timestamp(metadata: dict[str, Any], memory_text: str = "") -> Optional[str]: + """Return the original event timestamp when available.""" + timestamp = metadata.get("timestamp") + if isinstance(timestamp, str) and timestamp.strip(): + return timestamp.strip() + match = _EVENT_TEXT_PREFIX_RE.match(memory_text) + if match: + return match.group(1).strip() + filed_at = metadata.get("filed_at") or metadata.get("created_at") + if isinstance(filed_at, str) and filed_at.strip(): + return filed_at.strip() + return None + + @staticmethod + def _parse_memory_timestamp(timestamp: Any) -> Optional[float]: + """Parse an ISO memory timestamp back to a Unix timestamp.""" + if not isinstance(timestamp, str) or not timestamp.strip(): + return None + try: + return datetime.fromisoformat(timestamp.strip()).timestamp() + except ValueError: + return None + + @classmethod + def _event_to_text(cls, event: Event) -> str: + """Extract verbatim text-like content from an event.""" + if not event.content or not event.content.parts: + return "" + text = event_to_text(event) + if not text: + return "" + timestamp = format_timestamp(event.timestamp) + return f"[{timestamp}] {event.author}:\n{text}" diff --git a/trpc_agent_sdk/models/__init__.py b/trpc_agent_sdk/models/__init__.py new file mode 100644 index 000000000..fc9c8966a --- /dev/null +++ b/trpc_agent_sdk/models/__init__.py @@ -0,0 +1,98 @@ +# 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. +"""Model package initialization module. + +This module exports all public interfaces of the model system, +including base classes, request/response types, and implementations. +""" + +from ._anthropic_model import AnthropicModel +from ._constants import API_KEY +from ._constants import ASSISTANT +from ._constants import BASE_URL +from ._constants import CHOICES +from ._constants import CHUNK +from ._constants import CLIENT_ARGS +from ._constants import CONTENT +from ._constants import DELTA +from ._constants import FINISH_REASON +from ._constants import INDEX +from ._constants import MESSAGE +from ._constants import MODEL +from ._constants import ORGANIZATION +from ._constants import RAW_RESPONSE +from ._constants import REASONING_CONTENT +from ._constants import ROLE +from ._constants import SYSTEM +from ._constants import THINKING_ENABLED +from ._constants import THINKING_TOKENS +from ._constants import TOOL +from ._constants import TOOL_CALLS +from ._constants import TOOL_CALL_ID +from ._constants import TOOL_STREAMING +from ._constants import TOOL_STREAMING_ARGS +from ._constants import USAGE +from ._constants import USER +from ._litellm_model import LiteLLMModel +from ._llm_model import LLMModel +from ._llm_request import LlmRequest +from ._llm_response import LlmResponse +from ._openai_model import ApiParamsKey +from ._openai_model import FinishReason +from ._openai_model import OpenAIModel +from ._openai_model import ToolCall +from ._openai_model import ToolKey +from ._httpx_client import close_shared_http_clients +from ._httpx_client import temporary_http_client_provider_factory +from ._httpx_client import shared_http_client_provider_factory +from ._httpx_client import HttpClientProviderFactory +from ._registry import ModelRegistry +from ._registry import register_model + +__all__ = [ + "API_KEY", + "BASE_URL", + "CLIENT_ARGS", + "ORGANIZATION", + "ROLE", + "USER", + "ASSISTANT", + "SYSTEM", + "MODEL", + "TOOL", + "CONTENT", + "CHOICES", + "USAGE", + "MESSAGE", + "TOOL_CALLS", + "TOOL_CALL_ID", + "DELTA", + "INDEX", + "RAW_RESPONSE", + "FINISH_REASON", + "CHUNK", + "TOOL_STREAMING", + "REASONING_CONTENT", + "TOOL_STREAMING_ARGS", + "THINKING_ENABLED", + "THINKING_TOKENS", + "AnthropicModel", + "LiteLLMModel", + "LLMModel", + "LlmRequest", + "LlmResponse", + "ApiParamsKey", + "FinishReason", + "OpenAIModel", + "ToolCall", + "ToolKey", + "close_shared_http_clients", + "temporary_http_client_provider_factory", + "shared_http_client_provider_factory", + "HttpClientProviderFactory", + "ModelRegistry", + "register_model", +] diff --git a/trpc_agent_sdk/models/_anthropic_model.py b/trpc_agent_sdk/models/_anthropic_model.py new file mode 100644 index 000000000..ed13d4168 --- /dev/null +++ b/trpc_agent_sdk/models/_anthropic_model.py @@ -0,0 +1,813 @@ +# 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. +"""Anthropic model implementation module. + +This module provides the AnthropicModel class which implements the BaseModel interface +for interacting with Anthropic's Claude API. It supports both streaming and non-streaming +responses, tool calls, and various Anthropic-specific features. +""" + +import base64 +import json +from enum import Enum +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional +from typing_extensions import override + +import anthropic +import httpx +from anthropic import AsyncAnthropic +from anthropic import types as anthropic_types + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import GenerateContentResponseUsageMetadata +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import Tool + +from . import _constants as const +from ._llm_model import LLMModel +from ._llm_request import LlmRequest +from ._llm_response import LlmResponse +from ._httpx_client import BaseHttpClientProvider +from ._httpx_client import HttpClientProviderFactory +from ._httpx_client import shared_http_client_provider_factory +from ._registry import register_model + +_EPHEMERAL = "ephemeral" + + +def _build_cache_control(ttl: Optional[str]) -> Dict[str, Any]: + cache_control: Dict[str, Any] = {"type": _EPHEMERAL} + if ttl: + cache_control["ttl"] = ttl + return cache_control + + +def _stamp_last_block(blocks: List[Dict[str, Any]], cache_control: Dict[str, Any]) -> None: + for block in reversed(blocks): + block["cache_control"] = cache_control + return + + +def _apply_tools_cache_control(tools: List[anthropic_types.ToolParam], cache_control: Dict[str, Any]) -> None: + if tools: + tools[-1]["cache_control"] = cache_control + + +def _apply_system_cache_control(system: str, cache_control: Dict[str, Any]) -> List[Dict[str, Any]]: + return [{"type": "text", "text": system, "cache_control": cache_control}] + + +def _apply_messages_cache_control( + messages: List[anthropic_types.MessageParam], + cache_control: Dict[str, Any], +) -> None: + """Stamp a cache_control breakpoint on the last assistant message.""" + for message in reversed(messages): + if message.get("role") != "assistant": + continue + + content = message.get("content") + if isinstance(content, list) and content: + _stamp_last_block(content, cache_control) + return + + +class _FinishReason(str, Enum): + """Reasons why model generation finished.""" + + STOP = "stop" + MAX_TOKENS = "max_tokens" + ERROR = "error" + TOOL_USE = "tool_use" + + +class _ApiParamsKey(str, Enum): + """API parameter keys for Anthropic API calls.""" + + MODEL = const.MODEL + MESSAGES = "messages" + STREAM = "stream" + MAX_TOKENS = "max_tokens" + TEMPERATURE = "temperature" + TOP_P = "top_p" + TOP_K = "top_k" + STOP_SEQUENCES = "stop_sequences" + SYSTEM = "system" + TOOLS = "tools" + TOOL_CHOICE = "tool_choice" + THINKING = "thinking" + + +def _inject_cache_control( + api_params: Dict[str, Any], + breakpoints: List[str], + ttl: Optional[str], +) -> None: + """Inject Anthropic cache_control breakpoints into api_params in place.""" + cache_control = _build_cache_control(ttl) + if "tools" in breakpoints and api_params.get(_ApiParamsKey.TOOLS): + _apply_tools_cache_control(api_params[_ApiParamsKey.TOOLS], cache_control) + if "system" in breakpoints and api_params.get(_ApiParamsKey.SYSTEM): + system = api_params[_ApiParamsKey.SYSTEM] + if isinstance(system, str): + api_params[_ApiParamsKey.SYSTEM] = _apply_system_cache_control(system, cache_control) + else: + logger.warning( + "Anthropic system cache_control injection expects a string system " + "prompt, got %s; skipping system cache_control injection.", + type(system).__name__, + ) + if "messages" in breakpoints and api_params.get(_ApiParamsKey.MESSAGES): + _apply_messages_cache_control(api_params[_ApiParamsKey.MESSAGES], cache_control) + + +@register_model(model_name="AnthropicModel", supported_models=[r"claude-.*"]) +class AnthropicModel(LLMModel): + """Anthropic model implementation using the abstract model interface. + + This class provides integration with Anthropic's Claude API, supporting features like: + - Streaming and non-streaming responses + - Tool/function calling (native Anthropic format) + - Vision API for image inputs + - Default configuration via generate_content_config + + Args: + model_name: The Anthropic model name (e.g., "claude-3-5-sonnet-20241022") + filters_name: Optional list of filter names to apply + generate_content_config: Default configuration for all requests. This config + will be used as the base, with per-request configs + overriding specific fields. + **kwargs: Additional arguments passed to parent LLMModel class + (e.g., api_key, base_url, etc.) + """ + + def __init__( + self, + model_name: str, + filters_name: Optional[list[str]] = None, + generate_content_config: Optional[GenerateContentConfig] = None, + http_client_provider_factory: HttpClientProviderFactory = shared_http_client_provider_factory, + **kwargs, + ): + super().__init__(model_name, filters_name, **kwargs) + + # Extract Anthropic-specific config + self.client_args = kwargs.get(const.CLIENT_ARGS, {}) + # Allow callers to inject a tuned httpx client so the underlying openai.AsyncOpenAI honors connection-pool + # settings such as keepalive_expiry / max_keepalive_connections (avoids reusing stale + # keep-alive sockets that gateways close earlier than httpx). + http_client_provider_factory = http_client_provider_factory or shared_http_client_provider_factory + self._http_client_provider: BaseHttpClientProvider = http_client_provider_factory() + # Default generation config that can be overridden per request + self.generate_content_config = generate_content_config + + def _create_async_client(self) -> AsyncAnthropic: + """Create a new async client instance.""" + + # Disable httpx logging to prevent HTTP request logs + import logging + + logging.getLogger("httpx").setLevel(logging.WARNING) + client_args = self.client_args.copy() + client_args['http_client'] = self._http_client_provider.create_http_client() + + return AsyncAnthropic( + api_key=self._api_key, + max_retries=0, # disable retries + base_url=self._base_url if self._base_url else None, + **client_args, + ) + + def is_retriable_status_code(self, status_code: int) -> Optional[bool]: + return status_code in {408, 409, 429} or status_code >= 500 + + def is_retriable_exception(self, ex: Exception) -> bool: + if isinstance(ex, httpx.TimeoutException): + return True + + retryable_error = getattr(anthropic, "RetryableError", None) + if retryable_error is not None and isinstance(ex, retryable_error): + return True + if isinstance(ex, anthropic.APIConnectionError): + return True + if isinstance(ex, anthropic.APIStatusError): + return False + if isinstance(ex, anthropic.AnthropicError): + return False + return True + + def _to_claude_role(self, role: Optional[str]) -> Literal["user", "assistant"]: + """Convert role to Claude format.""" + if role in [const.MODEL, const.ASSISTANT]: + return "assistant" + return "user" + + def _is_image_part(self, part: Part) -> bool: + """Check if a part contains image data.""" + return (part.inline_data and part.inline_data.mime_type and part.inline_data.mime_type.startswith("image")) + + def _part_to_message_block( + self, part: Part + ) -> (anthropic_types.TextBlockParam + | anthropic_types.ImageBlockParam + | anthropic_types.ToolUseBlockParam + | anthropic_types.ToolResultBlockParam): + """Convert a Part to an Anthropic message block.""" + if part.text: + return anthropic_types.TextBlockParam(text=part.text, type="text") + elif part.function_call: + assert part.function_call.name + + return anthropic_types.ToolUseBlockParam( + id=part.function_call.id or "", + name=part.function_call.name, + input=part.function_call.args, + type="tool_use", + ) + elif part.function_response: + content = "" + response_data = part.function_response.response + + # Handle response with content array + if "content" in response_data and response_data["content"]: + content_items = [] + for item in response_data["content"]: + if isinstance(item, dict): + # Handle text content blocks + if item.get("type") == "text" and "text" in item: + content_items.append(item["text"]) + else: + # Handle other structured content + content_items.append(str(item)) + else: + content_items.append(str(item)) + content = "\n".join(content_items) if content_items else "" + # Handle traditional result format + elif "result" in response_data and response_data["result"]: + content = str(response_data["result"]) + else: + # Handle simple response format + content = json.dumps(response_data, ensure_ascii=False) + + return anthropic_types.ToolResultBlockParam( + tool_use_id=part.function_response.id or "", + type="tool_result", + content=content, + is_error=False, + ) + elif self._is_image_part(part): + data = base64.b64encode(part.inline_data.data).decode() # type: ignore + return anthropic_types.ImageBlockParam( + type="image", + source=dict( + type="base64", + media_type=part.inline_data.mime_type, + data=data # type: ignore + ), + ) + elif part.executable_code: + return anthropic_types.TextBlockParam( + type="text", + text="Code:```python\n" + part.executable_code.code + "\n```", + ) + elif part.code_execution_result: + return anthropic_types.TextBlockParam( + text="Execution Result:```code_output\n" + part.code_execution_result.output + "\n```", + type="text", + ) + + raise NotImplementedError(f"Not supported yet: {part}") + + def _format_messages(self, request: LlmRequest) -> List[anthropic_types.MessageParam]: + """Format contents for Anthropic API as messages.""" + formatted_messages = [] + + # Convert Contents to Anthropic message format + for content in request.contents: + # Determine role + role = self._to_claude_role(content.role) + + message_blocks = [] + for part in content.parts: # type: ignore + # Image data is not supported in Claude for model turns + if self._is_image_part(part) and role == "assistant": + logger.warning("Image data is not supported in Claude for model turns.") + continue + + message_blocks.append(self._part_to_message_block(part)) + + if message_blocks: + formatted_messages.append(anthropic_types.MessageParam(role=role, content=message_blocks)) + + return formatted_messages + + def _parse_finish_reason(self, stop_reason: Optional[str]) -> _FinishReason: + """Convert Anthropic stop reason to our enum.""" + if stop_reason in ["end_turn", "stop_sequence"]: + return _FinishReason.STOP + elif stop_reason == "max_tokens": + return _FinishReason.MAX_TOKENS + elif stop_reason == "tool_use": + return _FinishReason.TOOL_USE + return _FinishReason.ERROR + + def _update_type_string(self, value_dict: dict[str, Any]): + """Updates 'type' field to expected JSON schema format.""" + if "type" in value_dict: + value_dict["type"] = value_dict["type"].lower() + + if "items" in value_dict: + # 'type' field could exist for items as well + self._update_type_string(value_dict["items"]) + + if "properties" in value_dict["items"]: + # Recursively traverse each property + for _, value in value_dict["items"]["properties"].items(): + self._update_type_string(value) + + def _function_declaration_to_tool_param(self, function_declaration) -> anthropic_types.ToolParam: + """Convert a function declaration to an Anthropic tool param.""" + assert function_declaration.name + + # Use parameters_json_schema if available, otherwise convert from parameters + if hasattr(function_declaration, "parameters_json_schema") and function_declaration.parameters_json_schema: + input_schema = function_declaration.parameters_json_schema + else: + properties = {} + required_params = [] + if function_declaration.parameters: + if function_declaration.parameters.properties: + for key, value in function_declaration.parameters.properties.items(): + value_dict = value.model_dump(exclude_none=True) + self._update_type_string(value_dict) + properties[key] = value_dict + if function_declaration.parameters.required: + required_params = function_declaration.parameters.required + + input_schema = { + "type": "object", + "properties": properties, + } + if required_params: + input_schema["required"] = required_params + + return anthropic_types.ToolParam( + name=function_declaration.name, + description=function_declaration.description or "", + input_schema=input_schema, + ) + + def _convert_tools_to_anthropic_format(self, tools: List[Tool]) -> List[anthropic_types.ToolParam]: + """Convert tools to Anthropic tools format.""" + anthropic_tools = [] + + for tool in tools: + # Handle Tool objects with function_declarations + if tool.function_declarations: + for func_decl in tool.function_declarations: + anthropic_tools.append(self._function_declaration_to_tool_param(func_decl)) + + return anthropic_tools + + def _create_streaming_tool_call_response( + self, + accumulated_tool_uses: list[dict], + delta_json: str, + streaming_tool_names: Optional[set] = None, + ) -> Optional[LlmResponse]: + """Create a streaming tool call response with partial arguments. + + This method creates LlmResponse events for streaming tool call arguments, + allowing real-time display of tool call arguments as they are generated. + + Args: + accumulated_tool_uses: The accumulated tool uses so far (each dict has + 'id', 'name', 'accumulated_input') + delta_json: The delta JSON string from this chunk + streaming_tool_names: Set of tool names that should receive streaming events. + If None, all tools receive streaming events. + + Returns: + LlmResponse with partial tool call information, or None if no valid data + """ + if not accumulated_tool_uses: + return None + + # Get the current (last) tool being streamed + current_tool = accumulated_tool_uses[-1] + tool_name = current_tool.get("name", "") + tool_id = current_tool.get("id", "") + + if not tool_name: + return None + + # Only process tools that are in the streaming_tool_names set + if streaming_tool_names is not None and tool_name not in streaming_tool_names: + return None + + # Create function call part with delta only + # Agent layer accumulates deltas to build complete JSON + function_part = Part.from_function_call(name=tool_name, args={const.TOOL_STREAMING_ARGS: delta_json}) + + if tool_id: + function_part.function_call.id = tool_id # type: ignore + + streaming_content = Content(parts=[function_part], role=const.MODEL) + return LlmResponse( + content=streaming_content, + partial=True, + ) + + def _content_block_to_part(self, content_block: anthropic_types.ContentBlock) -> Part: + """Convert an Anthropic content block to a Part.""" + if isinstance(content_block, anthropic_types.TextBlock): + return Part.from_text(text=content_block.text) + if isinstance(content_block, anthropic_types.ToolUseBlock): + assert isinstance(content_block.input, dict) + part = Part.from_function_call(name=content_block.name, args=content_block.input) + part.function_call.id = content_block.id # type: ignore + return part + # Handle thinking blocks (extended thinking feature) + if isinstance(content_block, anthropic_types.ThinkingBlock): + part = Part.from_text(text=content_block.thinking) + part.thought = True # Mark as thinking content + return part + # Handle redacted thinking blocks + if isinstance(content_block, anthropic_types.RedactedThinkingBlock): + part = Part.from_text(text="[Thinking content redacted]") + part.thought = True # Mark as thinking content + return part + raise NotImplementedError(f"Not supported yet: {type(content_block)}") + + @staticmethod + def _build_usage_metadata(usage: anthropic_types.Usage) -> GenerateContentResponseUsageMetadata: + """Normalize Anthropic usage into a cache-inclusive shape. + + Anthropic ``input_tokens`` only counts tokens after the last cache + breakpoint. To report the full prompt size, fold cache read/write tokens + back into ``prompt_token_count``: + ``input_tokens + cache_read_input_tokens + cache_creation_input_tokens``. + """ + cache_read = usage.cache_read_input_tokens or 0 + cache_creation = usage.cache_creation_input_tokens or 0 + prompt_tokens = usage.input_tokens + cache_read + cache_creation + output_tokens = usage.output_tokens + return GenerateContentResponseUsageMetadata( + prompt_token_count=prompt_tokens, + candidates_token_count=output_tokens, + total_token_count=prompt_tokens + output_tokens, + cache_read_input_tokens=usage.cache_read_input_tokens, + cache_creation_input_tokens=usage.cache_creation_input_tokens, + ) + + def _message_to_llm_response(self, message: anthropic_types.Message) -> LlmResponse: + """Convert an Anthropic message to LlmResponse.""" + logger.info("Received response from Anthropic Claude.") + logger.debug( + "Claude response: %s", + message.model_dump_json(indent=2, exclude_none=True), + ) + + return LlmResponse( + content=Content( + role=const.MODEL, + parts=[self._content_block_to_part(cb) for cb in message.content], + ), + usage_metadata=self._build_usage_metadata(message.usage), + ) + + def _merge_configs(self, request_config: Optional[GenerateContentConfig]) -> GenerateContentConfig: + """Merge the default generate_content_config with the request config.""" + # If no request config provided, use default config if available + if not request_config: + if self.generate_content_config: + return self.generate_content_config.model_copy(deep=True) + return GenerateContentConfig() + + # If no default config, return request config as is + if not self.generate_content_config: + return request_config + + # Get explicitly set fields from request_config + request_set_fields = request_config.model_fields_set + + # Get explicitly set fields from default config + default_set_fields = self.generate_content_config.model_fields_set + + # Set default values on request_config for fields that are not already set + for field_name in default_set_fields: + if field_name not in request_set_fields: + # Only set if not already set in request_config + default_value = getattr(self.generate_content_config, field_name) + setattr(request_config, field_name, default_value) + # Update model_fields_set to reflect that this field is now set + request_config.model_fields_set.add(field_name) + + return request_config + + def _log_unsupported_config_options(self, config: GenerateContentConfig) -> None: + """Log warnings for configuration options that are not supported in Anthropic.""" + unsupported_options = [] + + if config.frequency_penalty is not None: + unsupported_options.append("frequency_penalty") + if config.presence_penalty is not None: + unsupported_options.append("presence_penalty") + if config.seed is not None: + unsupported_options.append("seed") + if config.response_logprobs is not None: + unsupported_options.append("response_logprobs") + if config.logprobs is not None: + unsupported_options.append("logprobs") + if config.candidate_count is not None and config.candidate_count > 1: + unsupported_options.append("candidate_count > 1") + if config.safety_settings: + unsupported_options.append("safety_settings") + if config.cached_content: + unsupported_options.append("cached_content") + if config.response_modalities: + unsupported_options.append("response_modalities") + if config.media_resolution: + unsupported_options.append("media_resolution") + if config.speech_config: + unsupported_options.append("speech_config") + if config.audio_timestamp: + unsupported_options.append("audio_timestamp") + if config.automatic_function_calling: + unsupported_options.append("automatic_function_calling") + + if unsupported_options: + logger.warning( + "The following configuration options are not supported in Anthropic models and will be ignored: %s", + ', '.join(unsupported_options), + ) + + async def _generate_single( + self, + api_params: Dict, + request: LlmRequest, + ctx: InvocationContext | None = None, + ) -> LlmResponse: + """Generate a single response (non-streaming).""" + client = self._create_async_client() + try: + response = await client.messages.create(**api_params) + return self._message_to_llm_response(response) + finally: + await self._http_client_provider.close_http_client(client) + + async def _generate_stream( + self, + api_params: Dict, + request: LlmRequest, + ctx: InvocationContext | None = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Generate streaming responses.""" + accumulated_content = "" + accumulated_thinking = "" + # Track tool uses with their accumulated input for streaming + # Each entry: {"id": str, "name": str, "index": int, "accumulated_input": str} + accumulated_tool_uses: list[dict] = [] + # Map content block index to tool use index in accumulated_tool_uses + block_index_to_tool_index: dict[int, int] = {} + + # Get the set of tool names that should stream + streaming_tool_names = getattr(request, 'streaming_tool_names', None) or set() + + client = self._create_async_client() + try: + logger.debug("Anthropic invoke with params: %s", api_params) + + async with client.messages.stream(**api_params) as stream: + async for event in stream: + logger.debug(f"Anthropic event: {event}") + # Handle content block delta events + if hasattr(event, "type") and event.type == "content_block_delta": + delta = event.delta + if hasattr(delta, "type") and delta.type == "text_delta": + text = delta.text + accumulated_content += text + + # Yield partial response + content_part = Part.from_text(text=text) + content_part.thought = False # Regular text content + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, partial=True) + + elif hasattr(delta, "type") and delta.type == "thinking_delta": + # Handle thinking content deltas + thinking_text = delta.thinking + accumulated_thinking += thinking_text + + # Yield partial thinking response + content_part = Part.from_text(text=thinking_text) + content_part.thought = True # Mark as thinking content + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, partial=True) + + elif hasattr(delta, "type") and delta.type == "input_json_delta": + # Tool use input streaming + partial_json = delta.partial_json + block_index = event.index if hasattr(event, "index") else -1 + + # Find the corresponding tool use and accumulate + if block_index in block_index_to_tool_index: + tool_idx = block_index_to_tool_index[block_index] + accumulated_tool_uses[tool_idx]["accumulated_input"] += partial_json + + # Yield streaming tool call event if enabled + if streaming_tool_names: + streaming_event = self._create_streaming_tool_call_response( + accumulated_tool_uses, + partial_json, + streaming_tool_names, + ) + if streaming_event: + yield streaming_event + + # Handle content block start events (for tool use) + elif hasattr(event, "type") and event.type == "content_block_start": + if hasattr(event, "content_block"): + content_block = event.content_block + if isinstance(content_block, anthropic_types.ToolUseBlock): + block_index = event.index if hasattr(event, "index") else len(accumulated_tool_uses) + tool_entry = { + "id": content_block.id, + "name": content_block.name, + "index": block_index, + "accumulated_input": "", + } + tool_idx = len(accumulated_tool_uses) + accumulated_tool_uses.append(tool_entry) + block_index_to_tool_index[block_index] = tool_idx + + # Get the final message for complete usage stats + final_message = await stream.get_final_message() + + # Yield final complete response + final_parts = [] + + # Add thinking content first if present + if accumulated_thinking: + thinking_part = Part.from_text(text=accumulated_thinking) + thinking_part.thought = True + final_parts.append(thinking_part) + + # Add regular content + if accumulated_content: + content_part = Part.from_text(text=accumulated_content) + content_part.thought = False + final_parts.append(content_part) + + # Add tool uses from the final message (not from accumulated events) + # This ensures we get the complete tool_use blocks with all input populated + for content_block in final_message.content: + if isinstance(content_block, anthropic_types.ToolUseBlock): + part = Part.from_function_call(name=content_block.name, args=content_block.input) + part.function_call.id = content_block.id # type: ignore + final_parts.append(part) + + final_content = None + if final_parts: + final_content = Content(parts=final_parts, role=const.MODEL) + + final_usage = self._build_usage_metadata(final_message.usage) + + yield LlmResponse(content=final_content, + usage_metadata=final_usage, + partial=False, + custom_metadata={"stream_complete": True}) + + except Exception: + logger.error("Error in streaming response", exc_info=True) + raise + finally: + await self._http_client_provider.close_http_client(client) + + def _apply_prompt_cache(self, api_params: Dict[str, Any], ctx: InvocationContext | None) -> None: + """Inject Anthropic native cache_control breakpoints (opt-in, no-op when disabled).""" + cache_config = self._resolve_prompt_cache_config(ctx) + if not cache_config or not cache_config.breakpoints: + return + _inject_cache_control(api_params, cache_config.breakpoints, cache_config.ttl) + + @override + async def _generate_async_impl(self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + """Generate content asynchronously.""" + self.validate_request(request) + + # Merge default config with request config + merged_config = self._merge_configs(request.config) + + # Update request with merged config + request.config = merged_config + + # Prepare Anthropic API parameters + messages = self._format_messages(request) + + # Debug log the formatted messages + logger.debug("Formatted messages for Anthropic API: %s", json.dumps([m for m in messages], indent=2)) + + api_params = { + _ApiParamsKey.MODEL: self._model_name, + _ApiParamsKey.MESSAGES: messages, + _ApiParamsKey.MAX_TOKENS: 8192, # Default max tokens + } + + # Add configuration parameters + try: + if request.config: + # Log warnings for unsupported configuration options + self._log_unsupported_config_options(request.config) + + # System instruction + if request.config.system_instruction: + api_params[_ApiParamsKey.SYSTEM] = str(request.config.system_instruction) + + # Max tokens + if request.config.max_output_tokens: + api_params[_ApiParamsKey.MAX_TOKENS] = request.config.max_output_tokens + + # Temperature + if request.config.temperature is not None: + api_params[_ApiParamsKey.TEMPERATURE] = request.config.temperature + + # Top P + if request.config.top_p is not None: + api_params[_ApiParamsKey.TOP_P] = request.config.top_p + + # Top K (Anthropic-specific) + if request.config.top_k is not None: + api_params[_ApiParamsKey.TOP_K] = request.config.top_k + + # Stop sequences + if request.config.stop_sequences: + api_params[_ApiParamsKey.STOP_SEQUENCES] = request.config.stop_sequences + + # Handle tools + if request.config.tools: + converted_tools = self._convert_tools_to_anthropic_format(request.config.tools) # type: ignore + if converted_tools: + api_params[_ApiParamsKey.TOOLS] = converted_tools + api_params[_ApiParamsKey.TOOL_CHOICE] = anthropic_types.ToolChoiceAutoParam(type="auto") + + # Handle thinking config (Anthropic extended thinking) + if request.config.thinking_config: + thinking_config = request.config.thinking_config + + # Only enable thinking if include_thoughts is True + if thinking_config.include_thoughts: + # Determine budget tokens + budget_tokens = 2048 # Default budget + + if thinking_config.thinking_budget is not None: + # thinking_budget: 0 is DISABLED, -1 is AUTOMATIC, >0 is specific token budget + if thinking_config.thinking_budget == 0: + # Explicitly disabled, skip thinking + logger.debug("Thinking explicitly disabled via thinking_budget=0") + elif thinking_config.thinking_budget == -1: + # Automatic mode - use default budget + budget_tokens = 2048 + else: + # Use the specified budget + budget_tokens = thinking_config.thinking_budget + + # Anthropic requires minimum 1024 tokens for thinking + if budget_tokens < 1024: + logger.warning( + "Thinking budget %s is below Anthropic's minimum of 1024 tokens. Adjusting to 1024.", + budget_tokens) + budget_tokens = 1024 + + # Only set thinking parameter if budget is positive + if thinking_config.thinking_budget != 0: + # Set the thinking parameter for Anthropic API + api_params[_ApiParamsKey.THINKING] = {"type": "enabled", "budget_tokens": budget_tokens} + logger.debug("Enabled Anthropic extended thinking with budget: %s tokens", budget_tokens) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error in Anthropic API parameters: %s", ex, exc_info=True) + raise ex + + self._apply_prompt_cache(api_params, ctx) + + if stream: + async for response in self._generate_stream(api_params, request, ctx): + yield response + else: + response = await self._generate_single(api_params, request, ctx) + yield response diff --git a/trpc_agent_sdk/models/_constants.py b/trpc_agent_sdk/models/_constants.py new file mode 100644 index 000000000..8d44809a2 --- /dev/null +++ b/trpc_agent_sdk/models/_constants.py @@ -0,0 +1,93 @@ +# 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. +"""Module containing constants used across the model implementations. + +This module defines string constants that are used as: +- Configuration keys +- API parameter names +- Response field names +""" + +# Configuration related constants +API_KEY: str = 'api_key' +"""API key configuration parameter name.""" + +BASE_URL: str = 'base_url' +"""Base URL configuration parameter name.""" + +CLIENT_ARGS: str = 'client_args' +"""Client arguments configuration parameter name.""" + +ORGANIZATION: str = 'organization' +"""Organization identifier parameter name.""" + +# Request/Response field constants +ROLE: str = 'role' +"""Role field name in message objects.""" + +USER: str = 'user' +"""User field name in message objects.""" + +ASSISTANT: str = 'assistant' +"""Assistant field name in message objects.""" + +SYSTEM: str = 'system' +"""System field name in message objects.""" + +MODEL: str = 'model' +"""Model field name in API requests.""" + +TOOL: str = 'tool' +"""Tool field name in message objects.""" + +CONTENT: str = 'content' +"""Content field name in message objects.""" + +CHOICES: str = 'choices' +"""Choices field name in API responses.""" + +USAGE: str = 'usage' +"""Usage statistics field name in API responses.""" + +MESSAGE: str = 'message' +"""Message field name in API responses.""" + +TOOL_CALLS: str = 'tool_calls' +"""Tool calls field name in API responses.""" + +TOOL_CALL_ID: str = 'tool_call_id' +"""Tool call ID field name in API responses.""" + +DELTA: str = 'delta' +"""Delta field name in streaming responses.""" + +INDEX: str = 'index' +"""Index field name in API responses.""" + +RAW_RESPONSE: str = 'raw_response' +"""Raw response field name.""" + +FINISH_REASON: str = 'finish_reason' +"""Finish reason field name in API responses.""" + +CHUNK: str = 'chunk' +"""Chunk field name in streaming responses.""" + +TOOL_STREAMING: str = 'tool_streaming' +"""Tool streaming mode indicator name.""" + +REASONING_CONTENT: str = 'reasoning_content' +"""Reasoning content field name.""" + +# thinking +THINKING_ENABLED: str = "thinking_enabled" +"""thinking enabled indicator name.""" + +THINKING_TOKENS: str = "thinking_tokens" +"""Thinking tokens field""" + +TOOL_STREAMING_ARGS: str = "tool_streaming_args" +"""Streaming tool call arguments delta key name.""" diff --git a/trpc_agent_sdk/models/_httpx_client.py b/trpc_agent_sdk/models/_httpx_client.py new file mode 100644 index 000000000..9db7e2d16 --- /dev/null +++ b/trpc_agent_sdk/models/_httpx_client.py @@ -0,0 +1,144 @@ +# 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. +"""HTTPX client implementation module. + +This module provides the HTTPX client implementation for TRPC Agent framework. +""" + +import httpx +import asyncio +import threading +import inspect +import os +from abc import ABC +from abc import abstractmethod +from typing import Callable +from typing import Optional +from typing import Any +from typing_extensions import override + +_DEFAULT_HTTP_CLIENT_LIMITS = httpx.Limits( + max_connections=1000, + max_keepalive_connections=100, + keepalive_expiry=30.0, +) +_DEFAULT_HTTP_CLIENT_TIMEOUT = httpx.Timeout(timeout=600.0, connect=5.0) + + +class BaseHttpClientProvider(ABC): + """Provider for HTTP clients.""" + + @abstractmethod + def create_http_client(self) -> httpx.AsyncClient: + """Create an HTTP client.""" + raise NotImplementedError("Subclasses must implement this method") + + @abstractmethod + async def close_http_client(self, client: Any) -> None: + """Close an HTTP client.""" + raise NotImplementedError("Subclasses must implement this method") + + +class TemporaryHttpClientProvider(BaseHttpClientProvider): + """Provider for temporary HTTP clients.""" + + @override + def create_http_client(self) -> Optional[httpx.AsyncClient]: + """Create a temporary HTTP client.""" + return None + + @override + async def close_http_client(self, client: Any) -> None: + """Close a temporary HTTP client.""" + close_method = getattr(client, "close", None) + if callable(close_method): + result = close_method() + if inspect.isawaitable(result): + await result + + +_shared_http_clients: dict[tuple[int, int], httpx.AsyncClient] = {} +_shared_http_clients_lock: threading.RLock = threading.RLock() + + +def _get_loop_key() -> int: + """Return a cache key for the current event loop, or a process-local fallback.""" + try: + return id(asyncio.get_running_loop()) + except RuntimeError: + return 0 + + +def _get_client_key() -> tuple[int, int]: + """Return a process-local and loop-local cache key for shared HTTP clients.""" + return os.getpid(), _get_loop_key() + + +def _reset_shared_http_clients_after_fork() -> None: + """Drop inherited clients and recreate the lock in a forked child process.""" + global _shared_http_clients_lock + _shared_http_clients.clear() + _shared_http_clients_lock = threading.RLock() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_shared_http_clients_after_fork) + + +def _create_shared_http_client() -> httpx.AsyncClient: + """Return a loop-local shared HTTP client with bounded keep-alive reuse. + + Returns: + A loop-local shared HTTP client with bounded keep-alive reuse. + """ + client_key = _get_client_key() + with _shared_http_clients_lock: + client = _shared_http_clients.get(client_key) + if client is None or client.is_closed: + client = httpx.AsyncClient( + limits=_DEFAULT_HTTP_CLIENT_LIMITS, + timeout=_DEFAULT_HTTP_CLIENT_TIMEOUT, + follow_redirects=True, + ) + _shared_http_clients[client_key] = client + return client + + +class SharedHttpClientProvider(BaseHttpClientProvider): + """Provider for shared HTTP clients.""" + + @override + def create_http_client(self) -> Optional[httpx.AsyncClient]: + """Create a shared HTTP client.""" + return _create_shared_http_client() + + @override + async def close_http_client(self, client: Any) -> None: + """Close a shared HTTP client.""" + return None + + +HttpClientProviderFactory = Callable[[], BaseHttpClientProvider] + + +def temporary_http_client_provider_factory() -> BaseHttpClientProvider: + """Provider for temporary HTTP clients.""" + return TemporaryHttpClientProvider() + + +def shared_http_client_provider_factory() -> BaseHttpClientProvider: + """Provider for shared HTTP clients.""" + return SharedHttpClientProvider() + + +async def close_shared_http_clients() -> None: + """Close HTTP clients created by the default HTTP client factory.""" + with _shared_http_clients_lock: + clients = list(_shared_http_clients.values()) + _shared_http_clients.clear() + for client in clients: + if not client.is_closed: + await client.aclose() diff --git a/trpc_agent_sdk/models/_litellm_model.py b/trpc_agent_sdk/models/_litellm_model.py new file mode 100644 index 000000000..fa3bc404d --- /dev/null +++ b/trpc_agent_sdk/models/_litellm_model.py @@ -0,0 +1,580 @@ +# 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. +"""LiteLLMModel: LLM via LiteLLM (provider/model, e.g. openai/gpt-4o). +Inherits OpenAIModel, overrides API to litellm.acompletion.""" + +import importlib.util +import json +import os +from enum import Enum +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import GenerateContentResponseUsageMetadata +from trpc_agent_sdk.types import Part + +from . import _constants as const +from ._llm_request import LlmRequest +from ._llm_response import LlmResponse +from ._openai_model import FinishReason +from ._openai_model import OpenAIModel +from ._registry import register_model +# Cache families for LiteLLM provider routing. +_ANTHROPIC_FAMILY = "anthropic" # uses cache_control breakpoints +_OPENAI_FAMILY = "openai_managed" # uses provider-managed prefix caching + +# LiteLLM provider prefixes (``provider/model``) that use cache_control breakpoints. +# Sources: +# - https://docs.litellm.ai/docs/tutorials/prompt_caching (official provider list) +_CACHE_CONTROL_PREFIXES = ( + "anthropic/", + "bedrock/", + "vertex_ai/", + "vertex_ai_beta/", + "gemini/", + "azure_ai/", + "openrouter/", + "databricks/", + "dashscope/", + "minimax/", + "zai/", +) + +# LiteLLM provider prefixes that use provider-managed prefix caching. +# Note: azure/ supports prompt_cache_key but NOT prompt_cache_retention — +# Azure OpenAI does not expose a TTL retention control in its API. +_MANAGED_PREFIXES = ( + "openai/", + "azure/", + "deepseek/", + "xai/", +) + + +def _litellm_cache_family(model_name: str) -> Optional[str]: + lowered = model_name.lower() + if lowered.startswith(_CACHE_CONTROL_PREFIXES): + return _ANTHROPIC_FAMILY + if lowered.startswith(_MANAGED_PREFIXES): + return _OPENAI_FAMILY + return None + + +_LITELLM_SUPPORTED_MODELS: List[str] = [ + r"openai/.*", + r"anthropic/.*", + r"groq/.*", + r"azure/.*", + r"gemini/.*", + r"vertex_ai/.*", + r"ollama/.*", + r"ollama_chat/.*", + r"together_ai/.*", + r"cohere/.*", + r"mistral/.*", + r"deepseek/.*", +] + + +def _is_litellm_gemini_model(model_string: str) -> bool: + """True for gemini/gemini-* or vertex_ai/gemini-*.""" + return model_string.startswith(("gemini/gemini-", "vertex_ai/gemini-")) + + +def _build_response_format_for_litellm( + response_schema: Any, + model_name: str, +) -> Optional[Dict[str, Any]]: + """Build response_format: Gemini → response_schema; OpenAI-compatible → json_schema.""" + schema_dict: Dict[str, Any] + schema_name: str = "response_schema" + if isinstance(response_schema, type) and hasattr(response_schema, "model_json_schema"): + schema_dict = response_schema.model_json_schema() # type: ignore[union-attr] + schema_name = getattr(response_schema, "__name__", schema_name) or schema_name + elif hasattr(response_schema, "model_dump"): + schema_dict = response_schema.model_dump() + schema_name = getattr(response_schema, "__name__", None) or getattr(getattr( + response_schema, "__class__", None), "__name__", None) or schema_dict.get("title", schema_name) + if not isinstance(schema_name, str): + schema_name = "response_schema" + elif isinstance(response_schema, dict): + schema_dict = dict(response_schema) + schema_name = str(schema_dict.get("title", "response_schema")) + else: + return None + + if _is_litellm_gemini_model(model_name): + return { + "type": "json_object", + "response_schema": schema_dict, + } + + if (isinstance(schema_dict, dict) and schema_dict.get("type") == "object" + and "additionalProperties" not in schema_dict): + schema_dict = dict(schema_dict) + schema_dict["additionalProperties"] = False + + return { + "type": "json_schema", + "json_schema": { + "name": schema_name, + "strict": True, + "schema": schema_dict, + }, + } + + +class _LiteLLMApiParamsKey(str, Enum): + MODEL = const.MODEL + MESSAGES = "messages" + STREAM = "stream" + TOOLS = "tools" + TOOL_CHOICE = "tool_choice" + RESPONSE_FORMAT = "response_format" + MAX_COMPLETION_TOKENS = "max_completion_tokens" + TEMPERATURE = "temperature" + TOP_P = "top_p" + STOP = "stop" + API_KEY = "api_key" + API_BASE = "api_base" + STREAM_OPTS = "stream_options" + EXTRA_BODY = "extra_body" + PROMPT_CACHE_KEY = "prompt_cache_key" + PROMPT_CACHE_RETENTION = "prompt_cache_retention" + CACHE_CONTROL_INJECTION_POINTS = "cache_control_injection_points" + + +@register_model(model_name="LiteLLMModel", supported_models=_LITELLM_SUPPORTED_MODELS) +class LiteLLMModel(OpenAIModel): + """model_name must be provider/model (e.g. openai/gpt-4o). kwargs: api_key, base_url→api_base.""" + + _litellm_imported: bool = False + + def __init__( + self, + model_name: str, + filters_name: Optional[list[str]] = None, + generate_content_config: Optional[GenerateContentConfig] = None, + **kwargs, + ): + if "/" not in model_name: + raise ValueError( + "model_name must be in provider/model format (e.g. openai/gpt-4o, anthropic/claude-3-5-sonnet)") + super().__init__( + model_name, + filters_name, + add_tools_to_prompt=False, + tool_prompt="xml", + generate_content_config=generate_content_config, + **kwargs, + ) + + def is_retriable_exception(self, ex: Exception) -> bool: + # LiteLLM normalizes provider errors and attaches status/headers, so the + # header/status path decides; class-based fallback would be unreliable. + return False + + def _ensure_litellm_imported(self) -> None: + """Lazy-import litellm; set LITELLM_MODE=PRODUCTION. Raises ImportError if not installed.""" + if LiteLLMModel._litellm_imported: + return + if importlib.util.find_spec("litellm") is None: + raise ImportError( + "LiteLLM support requires: pip install trpc-agent-py[litellm] or pip install litellm>=1.75.5") + os.environ.setdefault("LITELLM_MODE", "PRODUCTION") + LiteLLMModel._litellm_imported = True + + def _apply_prompt_cache(self, api_params: Dict[str, Any], ctx: InvocationContext | None) -> None: + """Apply prompt cache config to LiteLLM api_params (best-effort, in place).""" + cache_config = self._resolve_prompt_cache_config(ctx) + if not cache_config: + return + + family = _litellm_cache_family(self._model_name) + + if family is None: + logger.warning( + "prompt_cache_config is set but model %r has no recognized provider prefix; " + "cache config will be ignored. Use a 'provider/model' name (e.g. 'openai/gpt-4o') " + "so the SDK can select the correct cache mechanism.", + self._model_name, + ) + return + + if family == _ANTHROPIC_FAMILY: + if not cache_config.breakpoints: + return + ttl = cache_config.ttl + # tools breakpoint: stamp cache_control directly on the last tool + # (LiteLLM's _map_tool_helper transparently forwards it to Anthropic). + # Bedrock uses a separate tool_config cachePoint via injection_points. + if "tools" in cache_config.breakpoints: + self._apply_tools_cache_control(api_params, ttl) + points = self._build_cache_injection_points( + cache_config.breakpoints, + ttl, + api_params.get(_LiteLLMApiParamsKey.MESSAGES), + ) + if points: + api_params[_LiteLLMApiParamsKey.CACHE_CONTROL_INJECTION_POINTS] = points + elif family == _OPENAI_FAMILY: + if cache_config.prompt_cache_key: + api_params[_LiteLLMApiParamsKey.PROMPT_CACHE_KEY] = cache_config.prompt_cache_key + if cache_config.ttl: + if not self._model_name.lower().startswith("azure/"): + api_params[_LiteLLMApiParamsKey.PROMPT_CACHE_RETENTION] = cache_config.ttl + + def _apply_tools_cache_control(self, api_params: Dict[str, Any], ttl: Optional[str]) -> None: + """Stamp cache_control on the last tool in api_params (in place). + + For non-Bedrock Anthropic upstreams LiteLLM's _map_tool_helper forwards + a tool-level ``cache_control`` field directly to the Anthropic API, so we + mutate the tool list here rather than using cache_control_injection_points. + For Bedrock the injection_points mechanism handles tools via tool_config. + """ + if self._model_name.lower().startswith("bedrock/"): + return + tools = api_params.get(_LiteLLMApiParamsKey.TOOLS) + if not tools: + return + cache_control: Dict[str, Any] = {"type": "ephemeral"} + if ttl: + cache_control["ttl"] = ttl + tools[-1]["cache_control"] = cache_control + + def _build_cache_injection_points( + self, + breakpoints: List[str], + ttl: Optional[str], + messages: Any = None, + ) -> List[Dict[str, Any]]: + """Build LiteLLM ``cache_control_injection_points`` for system/messages/Bedrock-tools. + + - ``system`` -> stamps cache_control on the system message (by role). + - ``messages`` -> stamps cache_control on the most recent assistant + message, matching the native Anthropic adapter. + - ``tools`` -> Bedrock only: tool_config cachePoint (no control/ttl field; + LiteLLM always emits {"cachePoint": {"type": "default"}} for this). + Non-Bedrock tools are handled separately by _apply_tools_cache_control. + """ + + def _make_cache_control() -> Dict[str, Any]: + cache_control: Dict[str, Any] = {"type": "ephemeral"} + if ttl: + cache_control["ttl"] = ttl + return cache_control + + points: List[Dict[str, Any]] = [] + if "system" in breakpoints: + points.append({"location": "message", "role": "system", "control": _make_cache_control()}) + if "messages" in breakpoints: + assistant_index = self._last_assistant_message_index(messages) + if assistant_index is not None: + points.append({ + "location": "message", + "index": assistant_index, + "control": _make_cache_control(), + }) + if "tools" in breakpoints and self._model_name.lower().startswith("bedrock/"): + # Bedrock's tool_config cachePoint has no control/ttl field — + # LiteLLM ignores any control dict here and always emits + # {"cachePoint": {"type": "default"}}. + points.append({"location": "tool_config"}) + return points + + @staticmethod + def _last_assistant_message_index(messages: Any) -> Optional[int]: + if not isinstance(messages, list): + return None + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if isinstance(message, dict) and message.get("role") == "assistant": + return index + return None + + @staticmethod + def _set_extra_body(api_params: Dict[str, Any], key: str, value: Any) -> None: + """Set a key inside api_params' extra_body, reusing an existing dict if present. + + If an existing ``extra_body`` is not a dict (e.g. a string or some other + type), it is replaced and a warning is emitted so the caller is aware + that prior extra-body data has been discarded. + """ + current = api_params.get(_LiteLLMApiParamsKey.EXTRA_BODY) + if isinstance(current, dict): + current[key] = value + else: + if current is not None: + logger.warning( + "api_params['extra_body'] has unexpected type %s (expected dict); " + "replacing it to set %r. Existing extra_body content is lost.", + type(current).__name__, + key, + ) + api_params[_LiteLLMApiParamsKey.EXTRA_BODY] = {key: value} + + def _get_message_content(self, message: Any) -> str: + """Extract text from message.content (str or list of blocks). message: dict.""" + content = message.get("content") if message else None + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + texts: List[str] = [] + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "text" and "text" in block: + texts.append(block["text"]) + elif "text" in block: + texts.append(block["text"]) + return " ".join(texts) if texts else "" + return str(content) + + def _create_response_with_content(self, response_dict: Dict[str, Any], partial: bool = False) -> LlmResponse: + """Build LlmResponse from choices[0].message + usage.""" + choices = response_dict.get(const.CHOICES) or [] + if not choices: + usage_meta = super()._process_usage_from_response(response_dict) + return LlmResponse( + content=None, + usage_metadata=usage_meta, + error_code="NO_CHOICES", + error_message="No choices in response", + ) + + first_choice = choices[0] or {} + message = first_choice.get(const.MESSAGE) + if not message: + usage_meta = super()._process_usage_from_response(response_dict) + return LlmResponse( + content=None, + usage_metadata=usage_meta, + error_code="NO_MESSAGE", + error_message="No message in choice", + ) + + parts: List[Part] = [] + text = self._get_message_content(message) + if text: + content_part = Part.from_text(text=text) + content_part.thought = False + parts.append(content_part) + + message_dict = message if isinstance(message, dict) else (getattr(message, "model_dump", lambda: {})() or {}) + tool_calls = super()._process_tool_calls_from_message(message_dict) + if tool_calls: + for tool_call in tool_calls: + part = Part.from_function_call(name=tool_call.name, args=tool_call.arguments) + if tool_call.id and hasattr(part.function_call, "id"): + part.function_call.id = tool_call.id # type: ignore + parts.append(part) + + if not parts: + empty_part = Part.from_text(text="") + empty_part.thought = False + parts.append(empty_part) + + content = Content(parts=parts, role=const.MODEL) + usage_meta = super()._process_usage_from_response(response_dict) + error_code = None + finish_reason = first_choice.get(const.FINISH_REASON) + if finish_reason and finish_reason != FinishReason.STOP.value: + error_code = finish_reason + return LlmResponse(content=content, usage_metadata=usage_meta, partial=partial, error_code=error_code) + + @override + def _log_unsupported_config_options(self, config: GenerateContentConfig) -> None: + """Log unsupported config options (ignored by LiteLLM).""" + unsupported_options = [] + + if config.top_k is not None: + unsupported_options.append("top_k") + if config.response_logprobs is not None: + unsupported_options.append("response_logprobs") + if config.logprobs is not None and config.logprobs > 0: + unsupported_options.append("logprobs") + if config.candidate_count is not None and config.candidate_count > 1: + unsupported_options.append("candidate_count > 1") + if config.safety_settings: + unsupported_options.append("safety_settings") + if config.cached_content: + unsupported_options.append("cached_content") + if getattr(config, "response_modalities", None): + unsupported_options.append("response_modalities") + if getattr(config, "media_resolution", None): + unsupported_options.append("media_resolution") + if getattr(config, "speech_config", None): + unsupported_options.append("speech_config") + if getattr(config, "audio_timestamp", None): + unsupported_options.append("audio_timestamp") + if getattr(config, "automatic_function_calling", None): + unsupported_options.append("automatic_function_calling") + + if unsupported_options: + logger.warning( + "The following configuration options are not supported in LiteLLM models and will be ignored: " + f"{', '.join(unsupported_options)}", ) + + @override + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Generate via litellm.acompletion().""" + self._ensure_litellm_imported() + self.validate_request(request) + merged_config = self._merge_configs(request.config) + request.config = merged_config + messages = self._format_messages(request) + logger.debug("Formatted messages for LiteLLM API: %s", json.dumps(messages, indent=2)) + + try: + if request.config: + self._log_unsupported_config_options(request.config) + api_params: Dict[str, Any] = { + _LiteLLMApiParamsKey.MODEL: self._model_name, + _LiteLLMApiParamsKey.MESSAGES: messages, + _LiteLLMApiParamsKey.STREAM: stream, + } + if request.config and request.config.tools: + converted_tools = self._convert_tools_to_openai_format(request.config.tools) + if converted_tools: + api_params[_LiteLLMApiParamsKey.TOOLS] = converted_tools + if messages and messages[-1].get(const.ROLE) == const.TOOL: + api_params[_LiteLLMApiParamsKey.TOOL_CHOICE] = "none" + else: + api_params[_LiteLLMApiParamsKey.TOOL_CHOICE] = "auto" + if request.config and getattr(request.config, "response_schema", None): + rf = _build_response_format_for_litellm( + request.config.response_schema, + self._model_name, + ) + if rf is not None: + api_params[_LiteLLMApiParamsKey.RESPONSE_FORMAT] = rf + if request.config: + if request.config.max_output_tokens is not None: + api_params[_LiteLLMApiParamsKey.MAX_COMPLETION_TOKENS] = request.config.max_output_tokens + if request.config.temperature is not None: + api_params[_LiteLLMApiParamsKey.TEMPERATURE] = request.config.temperature + if request.config.top_p is not None: + api_params[_LiteLLMApiParamsKey.TOP_P] = request.config.top_p + if request.config.stop_sequences: + api_params[_LiteLLMApiParamsKey.STOP] = request.config.stop_sequences + if self._api_key: + api_params[_LiteLLMApiParamsKey.API_KEY] = self._api_key + if self._base_url: + api_params[_LiteLLMApiParamsKey.API_BASE] = self._base_url + api_params.update(self.config) + if stream: + api_params[_LiteLLMApiParamsKey.STREAM_OPTS] = {"include_usage": True} + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error in LiteLLM API parameters: %s", ex, exc_info=True) + raise + + self._apply_prompt_cache(api_params, ctx) + + if stream: + async for response in self._generate_stream(api_params, request, ctx): + yield response + else: + response = await self._generate_single(api_params, request, ctx) + yield response + + async def _generate_single( + self, + api_params: Dict[str, Any], + request: LlmRequest, + ctx: InvocationContext | None = None, + ) -> LlmResponse: + """One-shot acompletion → LlmResponse.""" + litellm = __import__("litellm") + acompletion = getattr(litellm, "acompletion") + response = await acompletion(**api_params) + response_dict: Dict[str, Any] = (response.model_dump() if hasattr(response, "model_dump") else response) + return self._create_response_with_content(response_dict, partial=False) + + async def _generate_stream( + self, + api_params: Dict[str, Any], + request: LlmRequest, + ctx: InvocationContext | None = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Stream via acompletion(stream=True); yield deltas then final LlmResponse.""" + try: + litellm = __import__("litellm") + acompletion = getattr(litellm, "acompletion") + stream_handle = await acompletion(**api_params) + + accumulated_content = "" + accumulated_tool_calls: List[Dict[str, Any]] = [] + usage_meta: Optional[GenerateContentResponseUsageMetadata] = None + + async for chunk in stream_handle: + if chunk is None: + continue + chunk_dict: Dict[str, Any] = (chunk.model_dump() if hasattr(chunk, "model_dump") else chunk) + choices = chunk_dict.get(const.CHOICES) or [] + if choices: + choice = choices[0] or {} + delta = (choice.get(const.DELTA) + if isinstance(choice, dict) else getattr(choice, "delta", None)) or {} + content_delta = (delta.get(const.CONTENT) if isinstance(delta, dict) else getattr( + delta, "content", None)) + tool_calls_data = delta.get(const.TOOL_CALLS) if isinstance(delta, dict) else None + if tool_calls_data: + for tc_delta in tool_calls_data or []: + if tc_delta is None: + continue + try: + self._process_tool_call_delta(tc_delta, accumulated_tool_calls) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing tool call delta: %s", ex) + if content_delta: + accumulated_content += content_delta + content_part = Part.from_text(text=content_delta) + content_part.thought = False + yield LlmResponse( + content=Content(parts=[content_part], role=const.MODEL), + partial=True, + custom_metadata={const.CHUNK: chunk_dict}, + ) + usage = super()._process_usage(chunk_dict) + if usage: + usage_meta = usage + parts: List[Part] = [] + if accumulated_content: + content_part = Part.from_text(text=accumulated_content) + content_part.thought = False + parts.append(content_part) + complete_tool_calls = self._create_complete_tool_calls(accumulated_tool_calls) or [] + for tool_call in complete_tool_calls: + part = Part.from_function_call(name=tool_call.name, args=tool_call.arguments) + if tool_call.id and hasattr(part.function_call, "id"): + part.function_call.id = tool_call.id # type: ignore + parts.append(part) + final_content = Content(parts=parts, role=const.MODEL) if parts else None + yield LlmResponse( + content=final_content, + usage_metadata=usage_meta, + partial=False, + custom_metadata={"stream_complete": True}, + ) + except Exception: + logger.error("Error in streaming response", exc_info=True) + raise diff --git a/trpc_agent_sdk/models/_llm_model.py b/trpc_agent_sdk/models/_llm_model.py new file mode 100644 index 000000000..2a93f53bb --- /dev/null +++ b/trpc_agent_sdk/models/_llm_model.py @@ -0,0 +1,217 @@ +# 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. +"""Base model interface module. + +This module defines the abstract base class (BaseModel) that serves as the foundation +for all model implementations in the system. It specifies the required interface and +common functionality that concrete model classes must implement. +""" + +from abc import abstractmethod +from functools import partial +from typing import AsyncGenerator +from typing import List +from typing import Optional +from typing import final + +from trpc_agent_sdk.configs import ModelRetryConfig +from trpc_agent_sdk.configs import PromptCacheConfig +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import create_agent_context +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.filter import FilterRunner +from trpc_agent_sdk.filter import FilterType +from . import _constants as const +from ._llm_request import LlmRequest +from ._llm_response import LlmResponse +from ._retry import ModelRetryInfo +from ._retry import _model_retry_info_from_exception +from ._retry import retry_model_call + +_VALID_ROLES: set[str] = {const.USER, const.ASSISTANT, const.MODEL, const.SYSTEM} + + +class LLMModel(FilterRunner): + """Abstract base class for all model implementations.""" + + def __init__( + self, + model_name: str, + filters_name: Optional[list[str]] = None, + prompt_cache_config: Optional[PromptCacheConfig] = None, + model_retry_config: Optional[ModelRetryConfig] = None, + **kwargs, + ): + filters: list = kwargs.get("filters", []) + super().__init__(filters_name=filters_name, filters=filters) + self._model_name = model_name + self.config = kwargs + self.prompt_cache_config = prompt_cache_config + self.model_retry_config = model_retry_config + self._type = FilterType.MODEL + self._init_filters() + self._api_key: str = kwargs.get(const.API_KEY, "") + self._base_url: str = kwargs.get(const.BASE_URL, "") + + def _resolve_prompt_cache_config( + self, + ctx: Optional[InvocationContext] = None, + ) -> Optional[PromptCacheConfig]: + """Resolve the effective prompt cache config for a call. + + The model-level ``prompt_cache_config`` is the baseline; per-run + ``RunConfig.prompt_cache`` (via ``ctx``) overrides it field-by-field, + so a run can tweak just one field (e.g. ``cache_key``) without having to + re-declare the rest. Returns the merged config only when it is enabled, + otherwise ``None`` (callers treat ``None`` as "do nothing"). + """ + base = self.prompt_cache_config + run = ctx.run_config.prompt_cache if (ctx is not None and ctx.run_config is not None) else None + + if run is None: + config = base + elif base is None: + config = run + else: + # Only fields explicitly set on the per-run config override the baseline. + config = base.model_copy(update=run.model_dump(exclude_unset=True)) + + if config is None or not config.enabled: + return None + return config + + def set_api_key(self, value: str) -> None: + """Set the API key.""" + self._api_key = value + + def set_base_url(self, value: str) -> None: + """Set the base URL.""" + self._base_url = value + + def set_model_name(self, value: str) -> None: + """Set the model name.""" + self._model_name = value + + def is_retriable_status_code(self, status_code: int) -> Optional[bool]: + """Map an HTTP status code to a retry decision. + + Return ``True``/``False`` for codes the provider has an opinion on, or + ``None`` to defer to :meth:`is_retriable_exception`. + """ + return None + + def is_retriable_exception(self, ex: Exception) -> bool: + """Fallback retry decision when no status code or header hint applies.""" + return False + + @final + def _get_model_retry_info(self, ex: Exception) -> ModelRetryInfo: + """Build retry info from headers/status, then the provider hooks. + + Providers customize behavior by overriding :meth:`is_retriable_status_code` + and :meth:`is_retriable_exception`, never this orchestration method. + """ + return _model_retry_info_from_exception( + ex, + self.is_retriable_status_code, + self.is_retriable_exception, + ) + + @classmethod + @abstractmethod + def supported_models(cls) -> List[str]: + """Return list of supported model name patterns (regex).""" + + @final + async def generate_async(self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + """Generate content asynchronously. + + Args: + request: The LLM request + stream: Whether to stream the response + + Yields: + LlmResponse objects. For non-streaming, yields one response. + For streaming, yields multiple partial responses. + Error responses should have error_code and error_message set. + """ + call_model = partial(self._generate_async_impl, request, stream, ctx) # type: ignore + error_code = "STREAMING_ERROR" if stream else "API_ERROR" + run_with_retry = partial( + retry_model_call, + call_model, + self.model_retry_config, + error_code=error_code, + get_retry_info=self._get_model_retry_info, + ) + extra_filters: list[BaseFilter] = [] + if ctx: + agent_context = ctx.agent_context + before_model_callback = getattr(ctx.agent, "before_model_callback", None) + after_model_callback = getattr(ctx.agent, "after_model_callback", None) + from trpc_agent_sdk.agents import ModelCallbackFilter + extra_filters.append(ModelCallbackFilter(before_model_callback, after_model_callback)) + else: + agent_context = create_agent_context() + + async for event in self._run_stream_filters(agent_context, request, run_with_retry, + extra_filters): # type: ignore + yield event # type: ignore + + @abstractmethod + async def _generate_async_impl(self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + """Generate content asynchronously. + + Args: + ctx: The invocation context + request: The LLM request + stream: Whether to stream the response + + Yields: + LlmResponse objects. For non-streaming, yields one response. + For streaming, yields multiple partial responses. + Error responses should have error_code and error_message set. + """ + + def validate_request(self, request: LlmRequest) -> None: + """Validate the request before processing. + + This method should check that the request is properly formed + and contains all required fields for this model implementation. + + Args: + request: The LLM request to validate + + Raises: + ValueError: If request is invalid + """ + if not request.contents: + raise ValueError("At least one content is required") + + # Validate content structure + for content in request.contents: + if not content.parts: + raise ValueError("Content must have at least one part") + + # Check if content has valid role + if content.role and content.role not in _VALID_ROLES: + raise ValueError(f"Invalid content role: {content.role}") + + @property + def name(self) -> str: + """Get the model name.""" + return self._model_name + + @property + def display_name(self) -> str: + """Get the display name for this model implementation.""" + return getattr(self.__class__, "_model_display_name", self.__class__.__name__) diff --git a/trpc_agent_sdk/models/_llm_request.py b/trpc_agent_sdk/models/_llm_request.py new file mode 100644 index 000000000..822c00dfc --- /dev/null +++ b/trpc_agent_sdk/models/_llm_request.py @@ -0,0 +1,125 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""LLM request class for TRPC Agent framework.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional +from typing import Set +from typing_extensions import override + +from pydantic import BaseModel + +from trpc_agent_sdk.abc import RequestABC +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Tool + + +class LlmRequest(RequestABC): + """LLM request class for TRPC Agent framework. + + This class allows passing in tools, output schema and system + instructions to the model. + + instructions to the model. + + Attributes: + model: The model name. + contents: The contents to send to the model. + config: Additional config for the generate content request. + tools_dict: The tools dictionary. + streaming_tool_names: Names of tools that should receive streaming arguments. + """ + + streaming_tool_names: Optional[Set[str]] = None + """Names of tools that should receive streaming arguments. + + When set, only tool calls for these tools will generate streaming events. + When None or empty, no streaming events will be generated for tool calls. + """ + + @override + def append_instructions(self, instructions: list[str]) -> None: + """Appends instructions to the system instruction. + + Args: + instructions: The instructions to append. + """ + + # Ensure config exists + if self.config is None: + self.config = GenerateContentConfig() + + new_instructions = "\n\n".join(instructions) + + if self.config.system_instruction: + # For simplicity, we'll convert to string and append + # Note: this assumes system_instruction is already a string or can be converted + existing_str = str(self.config.system_instruction) if self.config.system_instruction else "" + self.config.system_instruction = existing_str + "\n\n" + new_instructions + else: + self.config.system_instruction = new_instructions + + @override + def append_tools(self, tools: list[Any]) -> None: + """Appends tools to the request. + + Args: + tools: The tools to append. + """ + + if not tools: + return + + # Ensure config exists + if self.config is None: + self.config = GenerateContentConfig() + + # Ensure tools list exists + if self.config.tools is None: + self.config.tools = [] + + declarations = [] + for tool in tools: + declaration = tool._get_declaration() + if declaration: + declarations.append(declaration) + self.tools_dict[tool.name] = tool + if declarations: + self.config.tools.append(Tool(function_declarations=declarations)) + + @override + def set_output_schema(self, base_model: type[BaseModel]) -> None: + """Sets the output schema for the request. + + Args: + base_model: The pydantic base model to set the output schema to. + """ + + # Ensure config exists + if self.config is None: + self.config = GenerateContentConfig() + + self.config.response_schema = base_model + self.config.response_mime_type = "application/json" diff --git a/trpc_agent_sdk/models/_llm_response.py b/trpc_agent_sdk/models/_llm_response.py new file mode 100644 index 000000000..aa2fbc932 --- /dev/null +++ b/trpc_agent_sdk/models/_llm_response.py @@ -0,0 +1,105 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""LLM response class for TRPC Agent framework.""" + +from typing_extensions import override + +from trpc_agent_sdk.abc import ResponseABC +from trpc_agent_sdk.types import GenerateContentResponse + + +class LlmResponse(ResponseABC): + """LLM response class for TRPC Agent framework. + + This class provides the first candidate response from the + + If available, otherwise returns error code and message. + + Attributes: + content: The content of the response. + grounding_metadata: The grounding metadata of the response. + partial: Indicates whether the text content is part of a unfinished text + stream. Only used for streaming mode and when the content is plain text. + turn_complete: Indicates whether the response from the model is complete. + Only used for streaming mode. + error_code: Error code if the response is an error. Code varies by model. + error_message: Error message if the response is an error. + interrupted: Flag indicating that LLM was interrupted when generating the + content. Usually it's due to user interruption during a bidi streaming. + custom_metadata: The custom metadata of the LlmResponse. + """ + + def has_content(self) -> bool: + """Returns whether the response carries user-visible content (text or function call). + + Returns True if any content part contains text or a function call. Parts that + only hold function responses, executable code, or code execution results are + not considered content for this check. + """ + if not self.content or not self.content.parts: + return False + return any(p.text or p.function_call for p in self.content.parts) + + @override + def create( + self, + generate_content_response: GenerateContentResponse, + ) -> ResponseABC: + """Creates an LlmResponse from a GenerateContentResponse. + + Args: + generate_content_response: The GenerateContentResponse to create the + LlmResponse from. + + Returns: + The LlmResponse. + """ + usage_metadata = generate_content_response.usage_metadata + if generate_content_response.candidates: + candidate = generate_content_response.candidates[0] + if candidate.content and candidate.content.parts: + return LlmResponse( + content=candidate.content, + grounding_metadata=candidate.grounding_metadata, + usage_metadata=usage_metadata, + ) + else: + return LlmResponse( + error_code=candidate.finish_reason, + error_message=candidate.finish_message, + usage_metadata=usage_metadata, + ) + else: + if generate_content_response.prompt_feedback: + prompt_feedback = generate_content_response.prompt_feedback + return LlmResponse( + error_code=prompt_feedback.block_reason, + error_message=prompt_feedback.block_reason_message, + usage_metadata=usage_metadata, + ) + else: + return LlmResponse( + error_code="UNKNOWN_ERROR", + error_message="Unknown error.", + usage_metadata=usage_metadata, + ) diff --git a/trpc_agent_sdk/models/_openai_model.py b/trpc_agent_sdk/models/_openai_model.py new file mode 100644 index 000000000..bd30ae331 --- /dev/null +++ b/trpc_agent_sdk/models/_openai_model.py @@ -0,0 +1,1784 @@ +# 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. +"""OpenAI model implementation module. + +This module provides the OpenAIModel class which implements the BaseModel interface +for interacting with OpenAI's API. It supports both streaming and non-streaming +responses, tool calls, and various OpenAI-specific features. +""" + +import base64 +import json +import uuid +from enum import Enum +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Optional +from typing_extensions import override + +import httpx +import openai +from pydantic import BaseModel + +from trpc_agent_sdk.common import check_enum +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import GenerateContentResponseUsageMetadata +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair + +from . import _constants as const +from ._llm_model import LLMModel +from ._llm_request import LlmRequest +from ._llm_response import LlmResponse +from ._registry import register_model +from ._httpx_client import BaseHttpClientProvider +from ._httpx_client import HttpClientProviderFactory +from ._httpx_client import temporary_http_client_provider_factory +from .openai_adapter import get_openai_adapter +from .tool_prompt import ToolPromptFactory +from .tool_prompt import get_factory +from .tool_prompt import ToolPrompt + + +class ToolCall(BaseModel): + """Represents a tool call made by the model.""" + + id: str + name: str + arguments: Dict[str, Any] + thought_signature: Optional[str] = None + + +class FinishReason(str, Enum): + """Reasons why model generation finished.""" + + STOP = "stop" + LENGTH = "length" + ERROR = "error" + TOOL_CALLS = const.TOOL_CALLS + + +class ToolKey(str, Enum): + """Tool keys for tool calls.""" + + ID = "id" + TYPE = "type" + NAME = "name" + FUNCTION = "function" + ARGUMENTS = "arguments" + THOUGHT_SIGNATURE = "thought_signature" + PROVIDER_SPECIFIC_FIELDS = "provider_specific_fields" + + +class ApiParamsKey(str, Enum): + """Tool keys for tool calls.""" + + MODEL = const.MODEL + MESSAGES = "messages" + STREAM = "stream" + MAX_TOKENS = "max_tokens" + TEMPERATURE = "temperature" + TOP_P = "top_p" + STOP = "stop" + TOOLS = "tools" + TOOL_CHOICE = "tool_choice" + STREAM_OPTS = "stream_options" + INCLUDE_USAGE = "include_usage" + # Additional OpenAI parameters for better configuration support + FREQUENCY_PENALTY = "frequency_penalty" + PRESENCE_PENALTY = "presence_penalty" + SEED = "seed" + LOGPROBS = "logprobs" + TOP_LOGPROBS = "top_logprobs" + N = "n" + RESPONSE_FORMAT = "response_format" + MAX_COMPLETION_TOKENS = "max_completion_tokens" + REASONING_EFFORT = "reasoning_effort" + PARALLEL_TOOL_CALLS = "parallel_tool_calls" + PROMPT_CACHE_KEY = "prompt_cache_key" + PROMPT_CACHE_RETENTION = "prompt_cache_retention" + + +@register_model(model_name="OpenAIModel", supported_models=[r"gpt-.*", r"o1-.*", r"deepseek-.*", r"hy3-.*"]) +class OpenAIModel(LLMModel): + """OpenAI model implementation using the abstract model interface. + + This class provides integration with OpenAI's API, supporting features like: + - Streaming and non-streaming responses + - Tool/function calling (both native and via text prompts) + - Vision API for image inputs + - Default configuration via generate_content_config + + Args: + model_name: The OpenAI model name (e.g., "gpt-4", "gpt-3.5-turbo") + filters_name: Optional list of filter names to apply + add_tools_to_prompt: If True, tools are added to the system prompt as text + instead of using OpenAI's native function calling + tool_prompt: Tool prompt format to use when add_tools_to_prompt=True + (default: "xml") + generate_content_config: Default configuration for all requests. This config + will be used as the base, with per-request configs + overriding specific fields. Useful for maintaining + consistent model behavior across multiple calls. + **kwargs: Additional arguments passed to parent LLMModel class + (e.g., api_key, base_url, etc.) + + Example: + >>> # Create model with default config + >>> default_config = GenerateContentConfig( + ... temperature=0.7, + ... max_output_tokens=1000, + ... top_p=0.9 + ... ) + >>> model = OpenAIModel( + ... model_name="gpt-4", + ... api_key="your-api-key", + ... generate_content_config=default_config + ... ) + >>> + >>> # Request without config uses defaults + >>> request1 = LlmRequest( + ... contents=[Content(parts=[Part.from_text(text="Hello")])], + ... config=None # Will use temperature=0.7, max_output_tokens=1000, etc. + ... ) + >>> + >>> # Request with partial config overrides specific fields + >>> request2 = LlmRequest( + ... contents=[Content(parts=[Part.from_text(text="Hello")])], + ... config=GenerateContentConfig(temperature=0.3) # Override only temperature + ... ) + """ + + def __init__( + self, + model_name: str, + filters_name: Optional[list[str]] = None, + add_tools_to_prompt: bool = False, + tool_prompt: str = "xml", + generate_content_config: Optional[GenerateContentConfig] = None, + http_client_provider_factory: HttpClientProviderFactory = temporary_http_client_provider_factory, + **kwargs, + ): + super().__init__(model_name, filters_name, **kwargs) + self._adapter = get_openai_adapter(self._model_name, self._base_url) + + # Extract OpenAI-specific config + self.organization: str = kwargs.get(const.ORGANIZATION, "") + self.client_args = kwargs.get(const.CLIENT_ARGS, {}) + # Allow callers to inject a tuned httpx client + http_client_provider_factory = http_client_provider_factory or temporary_http_client_provider_factory + self._http_client_provider: BaseHttpClientProvider = http_client_provider_factory() + + # Tool prompt configuration + self.add_tools_to_prompt = add_tools_to_prompt + self.tool_prompt = tool_prompt + + # Default generation config that can be overridden per request + self.generate_content_config = generate_content_config + # Optional hard cap for tool-response payload injected into model context. + # Disabled by default; callers can opt in. + self._tool_response_clip_chars = int(kwargs.get("tool_response_clip_chars", 0) or 0) + + # Validate tool_prompt parameter + if isinstance(self.tool_prompt, str): + # Validate that the string is registered in factory + factory: ToolPromptFactory = get_factory() + try: + factory.create(self.tool_prompt) # Test creation to validate + except Exception as ex: # pylint: disable=broad-except + raise ValueError(f"Invalid tool_prompt string '{self.tool_prompt}': {ex}") + elif not (isinstance(self.tool_prompt, type) and issubclass(self.tool_prompt, ToolPrompt)): + raise ValueError(f"tool_prompt must be a string or ToolPrompt class, got {type(self.tool_prompt)}") + + def _refresh_adapter(self) -> None: + """Refresh provider adapter after model or endpoint changes.""" + self._adapter = get_openai_adapter(self._model_name, self._base_url) + + def is_retriable_status_code(self, status_code: int) -> Optional[bool]: + return status_code in {408, 409, 429} or status_code >= 500 + + def is_retriable_exception(self, ex: Exception) -> bool: + if isinstance(ex, httpx.TimeoutException): + return True + if isinstance(ex, openai.OpenAIError): + return False + return True + + @override + def set_base_url(self, value: str) -> None: + super().set_base_url(value) + self._refresh_adapter() + + @override + def set_model_name(self, value: str) -> None: + super().set_model_name(value) + self._refresh_adapter() + + def _create_async_client(self) -> openai.AsyncOpenAI: + """Create a new async client instance.""" + + # Disable httpx logging to prevent HTTP request logs + import logging + + logging.getLogger("httpx").setLevel(logging.WARNING) + + client_args = self.client_args.copy() + client_args['http_client'] = self._http_client_provider.create_http_client() + + return openai.AsyncOpenAI( + api_key=self._api_key, + max_retries=0, # disable retries + organization=self.organization, + base_url=self._base_url, + **client_args, + ) + + def _create_tool_prompt(self) -> ToolPrompt: + """Create a tool prompt instance from the blueprint.""" + if isinstance(self.tool_prompt, str): + # Get tool prompt from factory + factory: ToolPromptFactory = get_factory() + return factory.create(self.tool_prompt) + return self.tool_prompt() + + def _get_part_thought_signature(self, part: Part) -> str: + """Get thought_signature from Part as str; return dummy if missing. + See https://ai.google.dev/gemini-api/docs/thought-signatures (Gemini 3+). + """ + raw = getattr(part, "thought_signature", None) + if not raw: + return base64.b64encode(b"skip_thought_signature_validator").decode("utf-8") + if isinstance(raw, bytes): + return base64.b64encode(raw).decode("utf-8") + return raw + + def _set_part_thought_signature(self, function_part: Part, thought_signature: Optional[str]) -> None: + """Attach thought_signature to Part for next request. + Store as bytes to match Part schema and avoid serialization warnings. + """ + if not thought_signature: + return + sig = thought_signature + try: + setattr( + function_part, + "thought_signature", + base64.b64decode(sig) if isinstance(sig, str) else sig, + ) + except Exception: # pylint: disable=broad-except + setattr( + function_part, + "thought_signature", + sig.encode("utf-8") if isinstance(sig, str) else sig, + ) + + def _format_messages(self, request: LlmRequest) -> List[Dict[str, Any]]: + """Format contents for OpenAI API as messages.""" + formatted_messages = [] + + # Add system message if provided in config + system_text = "" + if request.config and request.config.system_instruction: + # Convert system_instruction to string if it's not already + system_text = str(request.config.system_instruction) + + # Add tool prompt to system message if enabled and tools are available + if self.add_tools_to_prompt and request.config and request.config.tools: + tool_prompt = self._create_tool_prompt() + tool_prompt_str = tool_prompt.build_prompt(request.config.tools) # type: ignore + if tool_prompt_str: + if system_text: + system_text += f"\n\n{tool_prompt_str}" + else: + system_text = tool_prompt_str + + # Add system message if we have any system content + if system_text: + request.config.system_instruction = system_text # type: ignore + formatted_messages.append({const.ROLE: const.SYSTEM, const.CONTENT: system_text}) + + # Convert Contents to OpenAI message format + for content in request.contents: + # Determine role - map different roles for OpenAI compatibility + role = content.role + if role == const.MODEL: + role = const.ASSISTANT # OpenAI uses const.ASSISTANT instead of const.MODEL + elif not role: + # Default role based on content type + role = const.USER # Default to user if no role specified + + parts: list[Part] = content.parts # type: ignore + conditions_iter = [ + len(parts) == 1, parts[0].text, parts[0].function_call, parts[0].function_response, + parts[0].code_execution_result, parts[0].executable_code, parts[0].inline_data + ] + # Handle different content structures + if all(conditions_iter): + # Simple text message + message = {const.ROLE: role, const.CONTENT: parts[0].text} + if self._adapter.should_backfill_reasoning_content(role, message): + message[const.REASONING_CONTENT] = "" + formatted_messages.append(message) + else: + # Complex message with multiple parts or function calls/responses + # Separate function responses from other content + function_responses: list[FunctionResponse] = [] + text_parts = [] + image_parts = [] + tool_calls = [] + + for part in parts: # type: ignore + if part.text: + if part.thought: + continue + text_parts.append(part.text) + elif part.inline_data and part.inline_data.mime_type: + # Handle image data - convert to OpenAI vision format + base64_string = base64.b64encode(part.inline_data.data).decode("utf-8") # type: ignore + data_uri = f"data:{part.inline_data.mime_type};base64,{base64_string}" + image_parts.append({"type": "image_url", "image_url": {"url": data_uri, "detail": "high"}}) + elif part.function_call: + # Only convert function call to OpenAI tool call format if add_tools_to_prompt is disabled + if not self.add_tools_to_prompt: + tool_call = { + "id": getattr(part.function_call, "id", None) or f"call_{uuid.uuid4().hex[:24]}", + "type": "function", + "function": { + "name": + part.function_call.name, + "arguments": (part.function_call.args if isinstance(part.function_call.args, str) + else json.dumps(part.function_call.args, ensure_ascii=False)), + }, + } + if self._adapter.should_include_thought_signature(): + tool_call["thought_signature"] = self._get_part_thought_signature(part) + tool_calls.append(tool_call) + # If add_tools_to_prompt is enabled, skip tool calls (they're handled via text prompts) + elif part.function_response: + # Collect function responses to be added as separate tool messages + function_responses.append(part.function_response) + elif part.code_execution_result: + # Handle code execution results - add to text parts + execution_result = f"{part.code_execution_result.outcome.value}" + if part.code_execution_result.output: + execution_result += f": {part.code_execution_result.output}" + result_text = f"CODE EXECUTION RESULT(DON'T SHOW THIS TEXT): {execution_result}\n" + text_parts.append(result_text) + elif part.executable_code: + # Handle executable code - add to text parts + if part.executable_code.language: + language = part.executable_code.language.value.lower() + code_text = f"```{language}\n{part.executable_code.code}\n```" + else: + code_text = f"```text\n{part.executable_code.code}\n```" + text_parts.append(code_text) + + # Handle function responses - role depends on add_tools_to_prompt setting + if self.add_tools_to_prompt: + # merge tool responses to correctly mach the qa pair + content = "" + for func_response in function_responses: + content += f"invoke {func_response.name}, get rsp: " + if isinstance(func_response.response, dict): + content += json.dumps(func_response.response, ensure_ascii=False) + else: + content += str(func_response.response) + content += "\n" + content = self._clip_tool_response_text(content, "tool_response_merged") + if len(content) > 0: + tool_message = { + const.ROLE: const.USER, + const.CONTENT: content, + } + formatted_messages.append(tool_message) + else: + for func_response in function_responses: + # Standard tool message format for OpenAI API + raw_text = (json.dumps(func_response.response, ensure_ascii=False) if isinstance( + func_response.response, dict) else str(func_response.response)) + clipped_text = self._clip_tool_response_text( + raw_text, + getattr(func_response, "name", "tool"), + ) + tool_message = { + const.ROLE: const.TOOL, + const.TOOL_CALL_ID: getattr(func_response, "id", "unknown"), + const.CONTENT: clipped_text, + } + formatted_messages.append(tool_message) + + # Create the main message (assistant/user) if it has content or tool calls + if text_parts or image_parts or tool_calls: + message: dict = {const.ROLE: role} + + # Handle content based on what we have + if image_parts or (text_parts and image_parts): + # Use array format for vision API when we have images + content_array = [] + + # Add text parts first + if text_parts: + content_array.append({"type": "text", "text": " ".join(text_parts)}) + + # Add image parts + content_array.extend(image_parts) + + message[const.CONTENT] = content_array + elif text_parts: + # Simple text content when no images + message[const.CONTENT] = " ".join(text_parts) + else: + message[const.CONTENT] = "" # Empty content if no text or tools + + # Add tool calls if any (only when add_tools_to_prompt is disabled) + if tool_calls and not self.add_tools_to_prompt: + message[const.TOOL_CALLS] = tool_calls + + if self._adapter.should_backfill_reasoning_content(role, message): + message[const.REASONING_CONTENT] = "" + + formatted_messages.append(message) + + # Validate and fix message sequence for OpenAI compatibility + return self._validate_and_fix_openai_messages(formatted_messages) + + def _validate_and_fix_openai_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Validate and fix message sequence to ensure OpenAI compatibility. + + OpenAI requires that assistant messages with tool_calls are immediately + followed by tool messages responding to each tool_call_id. + + Args: + messages: List of formatted messages + + Returns: + List of validated and potentially fixed messages + """ + if not messages: + return messages + + fixed_messages = [] + pending_tool_calls = [] # Track tool calls that need responses + + for message in messages: + role = message.get(const.ROLE, "") + + if role == const.ASSISTANT: + # Check if this assistant message has tool_calls + tool_calls = message.get(const.TOOL_CALLS, []) + + if tool_calls: + # If there are pending tool calls from a previous assistant message, + # we need to add dummy tool responses first + if pending_tool_calls: + logger.warning("Adding dummy tool responses for %s pending tool calls", len(pending_tool_calls)) + for pending_call in pending_tool_calls: + dummy_response = { + const.ROLE: const.TOOL, + const.TOOL_CALL_ID: pending_call["id"], + const.CONTENT: json.dumps({ + "status": "completed", + "note": "Tool call completed by system" + }), + } + fixed_messages.append(dummy_response) + + # Add the current assistant message + fixed_messages.append(message) + + # Update pending tool calls + pending_tool_calls = tool_calls + else: + # Assistant message without tool calls + if pending_tool_calls: + # Need to add dummy responses for pending tool calls + logger.warning("Adding dummy tool responses for %s pending tool calls before assistant message", + len(pending_tool_calls)) + for pending_call in pending_tool_calls: + dummy_response = { + const.ROLE: const.TOOL, + const.TOOL_CALL_ID: pending_call["id"], + const.CONTENT: json.dumps({ + "status": "completed", + "note": "Tool call completed by system" + }), + } + fixed_messages.append(dummy_response) + pending_tool_calls = [] + + # Add the assistant message + fixed_messages.append(message) + + elif role == const.TOOL: + # Tool message - remove matching tool call from pending + tool_call_id = message.get(const.TOOL_CALL_ID) + if tool_call_id and pending_tool_calls: + # Remove the matching pending tool call + pending_tool_calls = [tc for tc in pending_tool_calls if tc["id"] != tool_call_id] + + fixed_messages.append(message) + + else: + # User or system message + if pending_tool_calls: + # Add dummy responses for any pending tool calls before user/system message + logger.warning("Adding dummy tool responses for %s pending tool calls before %s message", + len(pending_tool_calls), role) + for pending_call in pending_tool_calls: + dummy_response = { + const.ROLE: const.TOOL, + const.TOOL_CALL_ID: pending_call["id"], + const.CONTENT: json.dumps({ + "status": "completed", + "note": "Tool call completed by system" + }), + } + fixed_messages.append(dummy_response) + pending_tool_calls = [] + + fixed_messages.append(message) + + # Handle any remaining pending tool calls at the end + if pending_tool_calls: + logger.warning("Adding dummy tool responses for %s remaining pending tool calls", len(pending_tool_calls)) + for pending_call in pending_tool_calls: + dummy_response = { + const.ROLE: const.TOOL, + const.TOOL_CALL_ID: pending_call["id"], + const.CONTENT: json.dumps({ + "status": "completed", + "note": "Tool call completed by system" + }), + } + fixed_messages.append(dummy_response) + + return fixed_messages + + def _parse_finish_reason(self, finish_reason: str) -> FinishReason: + """Convert OpenAI finish reason to our enum.""" + if not check_enum(finish_reason, FinishReason): + return FinishReason.ERROR + return FinishReason(finish_reason) + + def _verify_text_content_in_delta_response(self, response: dict) -> bool: + """Verify if the text content exists in the streaming response. + + Args: + response (`dict`): + The JSON-format response (After calling `model_dump` function) + + Returns: + `bool`: If the text content exists and is not empty + """ + choices: list[dict] = response.get(const.CHOICES, [{}]) + # Handle case where choices could be None (e.g., DeepSeek thinking events) + if choices is None or not choices: + return False + delta: dict = choices[0].get(const.DELTA, {}) + if delta is None: + return False + + # Check if regular content exists and is not None/empty + content = delta.get(const.CONTENT) + has_content = content is not None and content != "" + + # Check if reasoning content exists and is not None/empty + reasoning_content = delta.get(const.REASONING_CONTENT) + has_reasoning_content = reasoning_content is not None and reasoning_content != "" + + # Return True if either regular content or reasoning content exists + return has_content or has_reasoning_content + + def _is_thinking_event(self, response: dict) -> bool: + """Check if this is a thinking event from the model. + + Args: + response (`dict`): The JSON-format response + + Returns: + `bool`: True if this is a thinking event + """ + return (response.get("object", "") == "stream_server.event" + and response.get("event", {}).get("name", "") == "thinking") + + def _set_thinking(self, request: LlmRequest, http_options: dict): + """Set thinking parameters from request config.""" + # Check if thinking config is available in the request + if not request.config or not request.config.thinking_config: + return + + thinking_config = request.config.thinking_config + + if self._adapter.apply_thinking(request, http_options): + return + + # Only set thinking parameters if include_thoughts is True + if not thinking_config.include_thoughts: + return + + if "extra_body" not in http_options: + http_options["extra_body"] = {} + processed_extra_body = http_options["extra_body"] + + # Enable thinking + processed_extra_body[const.THINKING_ENABLED] = True + + # Set thinking budget if specified + if thinking_config.thinking_budget is not None: + max_output_tokens = request.config.max_output_tokens or 0 + if not max_output_tokens: + raise ValueError("max_output_tokens must be set when thinking is enabled") + + # Handle different thinking budget values + if thinking_config.thinking_budget == 0: + # 0 means disabled, so don't enable thinking + processed_extra_body[const.THINKING_ENABLED] = False + return + elif thinking_config.thinking_budget == -1: + # -1 means automatic, let the model decide + # Don't set thinking_tokens, let the model use its default + pass + elif thinking_config.thinking_budget > 0: + # Positive value means specific token budget + if thinking_config.thinking_budget <= max_output_tokens: + processed_extra_body[const.THINKING_TOKENS] = thinking_config.thinking_budget + else: + raise ValueError(f"thinking_budget: {thinking_config.thinking_budget} " + f"must be between 1024 and {max_output_tokens}") + else: + raise ValueError(f"Invalid thinking_budget value: {thinking_config.thinking_budget}. " + "Must be 0 (disabled), -1 (automatic), or positive integer.") + + def _get_thinking_state(self, response: dict) -> int: + """Get the thinking state from a thinking event. + + Args: + response (`dict`): The JSON-format response + + Returns: + `int`: The thinking state (0=start, 2=end, -1=not a thinking event) + """ + if self._is_thinking_event(response): + return response.get("event", {}).get("state", -1) + return -1 + + def _process_tool_call_delta(self, tool_call_delta: dict, accumulated_tool_calls: list[dict]) -> None: + """Process a single tool call delta and update accumulated tool calls. + + Args: + tool_call_delta (`dict`): The tool call delta to process + accumulated_tool_calls (`list`): The list of accumulated tool calls + """ + # Get the index of the tool call, handle None case + index = tool_call_delta.get(const.INDEX, 0) + if index is None: + index = 0 + + # Ensure we have enough slots in accumulated_tool_calls + while len(accumulated_tool_calls) <= index: + accumulated_tool_calls.append({ + ToolKey.ID: "", + ToolKey.TYPE: ToolKey.FUNCTION, + ToolKey.FUNCTION: { + ToolKey.NAME: "", + ToolKey.ARGUMENTS: "" + }, + ToolKey.THOUGHT_SIGNATURE: "", + }) + + # Capture thought_signature from delta or provider_specific_fields for next-round pass-through + thought_sig = tool_call_delta.get(ToolKey.THOUGHT_SIGNATURE) + if not thought_sig and isinstance(tool_call_delta.get(ToolKey.PROVIDER_SPECIFIC_FIELDS), dict): + thought_sig = tool_call_delta[ToolKey.PROVIDER_SPECIFIC_FIELDS].get(ToolKey.THOUGHT_SIGNATURE) + if thought_sig: + accumulated_tool_calls[index][ToolKey.THOUGHT_SIGNATURE] = thought_sig + + # Update the tool call with new information, preserving existing data + # Only update if the field exists and is not None in the delta + # For ID, preserve existing value if delta contains None or empty string (handles streaming inconsistencies) + if ToolKey.ID in tool_call_delta: + delta_id = tool_call_delta[ToolKey.ID] + if delta_id is not None and delta_id != "": + accumulated_tool_calls[index][ToolKey.ID] = delta_id + # If tool_call_delta[ToolKey.ID] is None or empty string, keep the existing ID value + + if ToolKey.FUNCTION in tool_call_delta: + function_delta = tool_call_delta[ToolKey.FUNCTION] + if (ToolKey.NAME in function_delta and function_delta[ToolKey.NAME] is not None + and function_delta[ToolKey.NAME] != ""): + accumulated_tool_calls[index][ToolKey.FUNCTION][ToolKey.NAME] = function_delta[ToolKey.NAME] + # If function_delta[ToolKey.NAME] is None or empty, keep the existing name value + if ToolKey.ARGUMENTS in function_delta and function_delta[ToolKey.ARGUMENTS] is not None: + accumulated_tool_calls[index][ToolKey.FUNCTION][ToolKey.ARGUMENTS] += function_delta[ToolKey.ARGUMENTS] + + @staticmethod + def _build_usage_metadata(usage_data: dict) -> GenerateContentResponseUsageMetadata: + """Build ``GenerateContentResponseUsageMetadata`` from a raw usage dict. + + ``cache_read_input_tokens`` prefers Anthropic/LiteLLM-style top-level fields; + falls back to OpenAI-style ``prompt_tokens_details.cached_tokens``. + """ + completion_details = usage_data.get("completion_tokens_details") or {} + cache_read = usage_data.get("cache_read_input_tokens") + if cache_read is None: + details = usage_data.get("prompt_tokens_details") + cache_read = details.get("cached_tokens") if isinstance(details, dict) else None + return GenerateContentResponseUsageMetadata( + prompt_token_count=usage_data.get("prompt_tokens", 0), + candidates_token_count=usage_data.get("completion_tokens", 0), + thoughts_token_count=completion_details.get("reasoning_tokens"), + total_token_count=usage_data.get("total_tokens", 0), + cache_read_input_tokens=cache_read, + cache_creation_input_tokens=usage_data.get("cache_creation_input_tokens"), + ) + + def _process_usage(self, chunk_dict: dict) -> Optional[GenerateContentResponseUsageMetadata]: + """Extract usage metadata from a streaming chunk dict.""" + usage_data = chunk_dict.get(const.USAGE) + return self._build_usage_metadata(usage_data) if usage_data is not None else None + + def _process_chunk_without_content( + self, chunk_dict: dict, accumulated_tool_calls: list[dict] + ) -> tuple[Optional[FinishReason], Optional[GenerateContentResponseUsageMetadata], dict[int, str]]: + """Process a chunk that doesn't contain content. + + Args: + chunk_dict (`dict`): The chunk dictionary to process + accumulated_tool_calls (`list`): The list of accumulated tool calls + + Returns: + Tuple of (finish_reason, usage_metadata, delta_arguments). + delta_arguments maps tool index to this chunk's argument delta string. + """ + choices = chunk_dict.get(const.CHOICES, [{}]) + # Handle case where choices could be None (e.g., DeepSeek thinking events) + if choices is None or not choices: + # Return early with only usage data if available + usage = self._process_usage(chunk_dict) + return None, usage, {} + choice: dict = choices[0] + + delta: dict = choice.get(const.DELTA, {}) + if delta is None: + return None, None, {} + + finish_reason = None + + # Handle finish reason + if choice.get(const.FINISH_REASON): + finish_reason = self._parse_finish_reason(choice[const.FINISH_REASON]) + + # Handle usage + usage = self._process_usage(chunk_dict) + + # Handle tool calls in chunks without content (this is where streaming tool calls happen) + tool_calls_data = delta.get(const.TOOL_CALLS) + + # Track delta arguments from this chunk + delta_arguments: dict[int, str] = {} + + if tool_calls_data and tool_calls_data is not None: + for tool_call_delta in tool_calls_data: + if tool_call_delta is None: + continue + try: + # Extract delta arguments before processing (for delta mode) + index = tool_call_delta.get(const.INDEX, 0) or 0 + function_delta = tool_call_delta.get(ToolKey.FUNCTION, {}) + if function_delta and ToolKey.ARGUMENTS in function_delta: + delta_arg = function_delta.get(ToolKey.ARGUMENTS) + if delta_arg is not None: + delta_arguments[index] = delta_arg + + self._process_tool_call_delta(tool_call_delta, accumulated_tool_calls) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing tool call delta: %s", ex) + continue + + return finish_reason, usage, delta_arguments + + def _create_complete_tool_calls(self, accumulated_tool_calls: list[dict]) -> Optional[List[ToolCall]]: + """Create ToolCall objects only for complete tool calls with valid data. + + Args: + accumulated_tool_calls (`list`): The list of accumulated tool calls + + Returns: + `Optional[List[ToolCall]]`: List of complete tool calls or None + """ + if not accumulated_tool_calls: + return None + + complete_tool_calls = [] + for i, tool_call_data in enumerate(accumulated_tool_calls): + # Only create ToolCall if we have complete data + function_map: dict = tool_call_data.get(ToolKey.FUNCTION, {}) + + # Check if we have the essential fields (name and arguments) + has_name = ToolKey.NAME in function_map and function_map[ToolKey.NAME] + has_arguments = ToolKey.ARGUMENTS in function_map + + if has_name and has_arguments: + try: + # Streaming tool-call accumulator: keep STRICT json.loads here. + # Incomplete deltas (e.g. ``{"foo":``) must raise so the loop + # can wait for the next chunk; using a repair-style parser + # would prematurely emit half-formed tool calls. + arguments_str: str = function_map[ToolKey.ARGUMENTS].strip() + if arguments_str: + arguments = json.loads(arguments_str) + else: + arguments = {} + + # Handle missing or empty ID by generating a fallback + tool_call_id = tool_call_data.get(ToolKey.ID, "") + if not tool_call_id: + # Generate a fallback ID if missing + tool_call_id = f"call_{uuid.uuid4().hex[:24]}" + logger.warning("Generated fallback ID '%s' for tool call with missing ID", tool_call_id) + + thought_sig = tool_call_data.get(ToolKey.THOUGHT_SIGNATURE) or None + logger.debug("Creating tool call: id=%s, name=%s, arguments=%s", tool_call_id, + function_map[ToolKey.NAME], arguments) + complete_tool_calls.append( + ToolCall( + id=tool_call_id, + name=function_map[ToolKey.NAME], + arguments=arguments, + thought_signature=thought_sig, + )) + except json.JSONDecodeError as ex: + # Arguments not complete yet, skip this tool call + logger.debug("JSON decode error for tool call %s: %s", i, ex) + continue + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to create complete tool call: %s, error: %s", tool_call_data, ex) + continue + + return complete_tool_calls if complete_tool_calls else None + + def _create_streaming_tool_call_response( + self, + accumulated_tool_calls: list[dict], + delta_arguments: Optional[dict[int, str]] = None, + streaming_tool_names: Optional[set] = None, + ) -> Optional[LlmResponse]: + """Create a streaming tool call response with delta arguments. + + This method creates LlmResponse events for streaming tool call arguments, + allowing real-time display of tool call arguments as they are generated. + Only the delta (new content from this chunk) is included in the response. + The agent layer is responsible for accumulating deltas. + + Args: + accumulated_tool_calls: The accumulated tool calls so far (used to get name/id) + delta_arguments: Dict mapping tool index to this chunk's delta arguments. + streaming_tool_names: Set of tool names that should receive streaming events. + If None, all tools receive streaming events. + + Returns: + LlmResponse with delta tool call information, or None if no valid data + """ + if not accumulated_tool_calls: + return None + + parts = [] + for idx, tool_call_data in enumerate(accumulated_tool_calls): + function_map: dict = tool_call_data.get(ToolKey.FUNCTION, {}) + name = function_map.get(ToolKey.NAME, "") + tool_call_id = tool_call_data.get(ToolKey.ID, "") + + if not name: + continue + + # Only process tools that are in the streaming_tool_names set + if streaming_tool_names is not None and name not in streaming_tool_names: + continue + + # Only process tool calls that have delta updates in this chunk + if delta_arguments is None or idx not in delta_arguments: + continue + + delta = delta_arguments[idx] + # Delta mode: send only the delta for this chunk + # Agent layer accumulates deltas to build complete JSON + function_part = Part.from_function_call(name=name, args={const.TOOL_STREAMING_ARGS: delta}) + + if tool_call_id: + function_part.function_call.id = tool_call_id # type: ignore + + parts.append(function_part) + + if not parts: + return None + + streaming_content = Content(parts=parts, role=const.MODEL) + return LlmResponse( + content=streaming_content, + partial=True, + ) + + def _verify_text_content_in_openai_message_response( + self, + response: dict, + allow_content_none: bool = False, + ) -> bool: + """Verify if the text content exists in the openai message response. + + Args: + response (`dict`): + The JSON-format OpenAI response (After calling `model_dump` + function) + allow_content_none (`bool`, defaults to `False`): + If the content can be `None` + + Returns: + `bool`: If the text content exists + """ + choices: list[dict] = response.get(const.CHOICES, [{}]) + # Handle case where choices could be None (e.g., DeepSeek thinking events) + if choices is None or not choices: + return False + if const.MESSAGE not in choices[0]: + return False + + if not allow_content_none: + return const.CONTENT in choices[0][const.MESSAGE] + + return True + + def _process_tool_calls_from_message(self, message: dict) -> Optional[List[ToolCall]]: + """Process tool calls from a message. + + Args: + message (`dict`): The message containing tool calls + + Returns: + `Optional[List[ToolCall]]`: List of processed tool calls or None + """ + tool_calls_data = message.get(const.TOOL_CALLS, []) + if not tool_calls_data: + return None + + tool_calls = [] + for tool_call in tool_calls_data: + if tool_call is None: + continue + try: + thought_sig = tool_call.get(ToolKey.THOUGHT_SIGNATURE) + if not thought_sig and isinstance(tool_call.get(ToolKey.PROVIDER_SPECIFIC_FIELDS), dict): + thought_sig = tool_call[ToolKey.PROVIDER_SPECIFIC_FIELDS].get(ToolKey.THOUGHT_SIGNATURE) + arguments = json_loads_repair(tool_call[ToolKey.FUNCTION][ToolKey.ARGUMENTS]) + if not isinstance(arguments, dict): + # json_repair can turn unrecoverable text (e.g. "NOT_JSON") + # into an empty string or list. Skip those so we never feed + # ToolCall a non-dict ``arguments`` value. + logger.warning( + "Skipping tool call with non-dict repaired arguments: %s -> %r", + tool_call, + arguments, + ) + continue + tool_calls.append( + ToolCall( + id=tool_call[ToolKey.ID], + name=tool_call[ToolKey.FUNCTION][ToolKey.NAME], + arguments=arguments, + thought_signature=thought_sig, + )) + except (KeyError, json.JSONDecodeError, TypeError) as ex: + logger.warning("Failed to parse tool call: %s, error: %s", tool_call, ex) + continue + + return tool_calls or None + + def _process_usage_from_response(self, response_dict: dict) -> Optional[GenerateContentResponseUsageMetadata]: + """Extract usage metadata from a non-streaming response dict.""" + usage_data = response_dict.get(const.USAGE) + return self._build_usage_metadata(usage_data) if usage_data is not None else None + + def _create_response_without_content(self, response_dict: dict) -> LlmResponse: + """Create a LlmResponse without content.""" + # Parse usage if available + usage = self._process_usage_from_response(response_dict) + + # Get finish reason + choices: list[dict] = response_dict.get(const.CHOICES, [{}]) + error_code = None + if choices and choices[0].get(const.FINISH_REASON): + finish_reason_value = choices[0][const.FINISH_REASON] + if finish_reason_value != FinishReason.STOP.value: + error_code = finish_reason_value + + # Get response ID + response_id = response_dict.get("id") + + return LlmResponse(content=None, usage_metadata=usage, error_code=error_code, response_id=response_id) + + def _create_response_with_content(self, response_dict: dict) -> LlmResponse: + """Create a LlmResponse with text content.""" + choices: list[dict] = response_dict.get(const.CHOICES, [{}]) + choice = choices[0] if choices else {} + message: dict = choice.get(const.MESSAGE, {}) + + # Extract content + text_content = message.get(const.CONTENT, "") + reasoning_content = message.get(const.REASONING_CONTENT) + + # Check for tool calls + tool_calls = self._process_tool_calls_from_message(message) + + # If add_tools_to_prompt is enabled and we have text content, try to parse function calls from it + if self.add_tools_to_prompt and text_content and not tool_calls: + try: + tool_prompt = self._create_tool_prompt() + parsed_function_calls = self._adapter.parse_tool_prompt_function_calls(text_content, tool_prompt) + if parsed_function_calls: + # Convert FunctionCall objects to ToolCall objects + tool_calls = [] + for func_call in parsed_function_calls: + tool_call = ToolCall( + id=f"call_{uuid.uuid4().hex[:24]}", + name=func_call.name, # type: ignore + arguments=func_call.args) # type: ignore + tool_calls.append(tool_call) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to parse function calls from text content: %s", ex) + + parts = [] + + if reasoning_content: + content_part = Part.from_text(text=reasoning_content) + content_part.thought = True + parts.append(content_part) + + # Add text content if present + if text_content and not (tool_calls and self._adapter.should_suppress_tool_prompt_text()): + content_part = Part.from_text(text=text_content) + content_part.thought = False # Regular text content is not thought + parts.append(content_part) + + # Add function calls if present + if tool_calls: + for tool_call in tool_calls: + # Create Part with function_call using the from_function_call method + function_part = Part.from_function_call(name=tool_call.name, args=tool_call.arguments) + # Set the id if available + if tool_call.id: + function_part.function_call.id = tool_call.id # type: ignore + self._set_part_thought_signature(function_part, tool_call.thought_signature) + parts.append(function_part) + + # Create content with parts + if parts: + content = Content(parts=parts, role=const.MODEL) + else: + # Fallback to empty text content if no parts + empty_part = Part.from_text(text="") + empty_part.thought = False # Empty content is not thought + content = Content(parts=[empty_part], role=const.MODEL) + + # Parse usage if available + usage = self._process_usage_from_response(response_dict) + + # Get finish reason for error handling + error_code = None + if choice.get(const.FINISH_REASON) and choice[const.FINISH_REASON] != FinishReason.STOP.value: + error_code = choice[const.FINISH_REASON] + + # Get response ID + response_id = response_dict.get("id") + + return LlmResponse(content=content, usage_metadata=usage, error_code=error_code, response_id=response_id) + + async def _generate_single(self, + api_params: Dict, + request: LlmRequest, + http_options: Dict[str, Any] | None = None, + ctx: InvocationContext | None = None) -> LlmResponse: + """Generate a single response (non-streaming).""" + if http_options is None: + http_options = {} + client = self._create_async_client() + try: + response = await client.chat.completions.create(**api_params, **http_options) + response_dict: dict = response.model_dump() + + # Check if the response contains valid text content or tool calls + has_text_content = self._verify_text_content_in_openai_message_response(response_dict) + has_tool_calls = False + + # Check for tool calls + choices: list[dict] = response_dict.get(const.CHOICES, [{}]) + if choices and choices[0].get(const.MESSAGE, {}).get(const.TOOL_CALLS): + has_tool_calls = True + + # Create response with content if we have text or tool calls + if has_text_content or has_tool_calls: + return self._create_response_with_content(response_dict) + return self._create_response_without_content(response_dict) + finally: + await self._http_client_provider.close_http_client(client) + + def _convert_tools_to_openai_format(self, tools: List[Tool]) -> List[Dict[str, Any]]: + """Convert Google GenAI tools format to OpenAI tools format. + + Args: + tools: List of Google GenAI Tool objects + + Returns: + List of OpenAI-formatted tool dictionaries + """ + openai_tools = [] + + for tool in tools: + # Handle Google GenAI Tool objects with function_declarations + if tool.function_declarations: + for func_decl in tool.function_declarations: + openai_tool = { + "type": "function", + "function": { + "name": func_decl.name or "", + "description": func_decl.description or "", + }, + } + + # Convert parameters schema - always include parameters field + if func_decl.parameters: + openai_tool["function"]["parameters"] = self._convert_schema_to_openai_format( + func_decl.parameters) + else: + # When parameters are empty, provide the proper OpenAI format structure + openai_tool["function"]["parameters"] = {"type": "object", "properties": {}} + + openai_tools.append(openai_tool) + + # Handle already converted/direct OpenAI format + elif isinstance(tool, dict) and tool.get("type") == "function": + openai_tools.append(tool) + + return openai_tools + + def _clip_tool_response_text(self, text: str, tool_name: str) -> str: + """Hard-clip tool response text to protect model context budget.""" + limit = self._tool_response_clip_chars + if limit <= 0 or len(text) <= limit: + return text + truncated = len(text) - limit + suffix = f"\n...[TRUNCATED {truncated} CHARS FROM TOOL RESPONSE: {tool_name}]" + keep = max(0, limit - len(suffix)) + return text[:keep] + suffix + + def _convert_schema_to_openai_format(self, schema: Schema) -> Dict[str, Any]: + """Convert Google GenAI Schema to OpenAI parameters format. + + Args: + schema: Google GenAI Schema object + + Returns: + OpenAI-formatted parameters dictionary + """ + if not schema: + # Return proper OpenAI format structure for empty schema + return {"type": "object", "properties": {}} + + result = {} + + # Handle type + if schema.type: + # Convert Google GenAI Type enum to string + if hasattr(schema.type, "value"): + result["type"] = schema.type.value.lower() + else: + result["type"] = str(schema.type).lower() + else: + # Default to object type if not specified + result["type"] = "object" + + # Handle properties + if schema.properties: + result["properties"] = {} + for prop_name, prop_schema in schema.properties.items(): + result["properties"][prop_name] = self._convert_schema_to_openai_format(prop_schema) + else: + # Ensure properties field exists for object type + if result.get("type") == "object": + result["properties"] = {} + + # Handle description + if schema.description: + result["description"] = schema.description + + # Handle required fields + if schema.required: + result["required"] = schema.required + + # Handle items for arrays + if schema.items: + result["items"] = self._convert_schema_to_openai_format(schema.items) + + # Handle additional properties + if schema.additional_properties is not None: + result["additionalProperties"] = schema.additional_properties + + return result + + def _ensure_additional_properties_false(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Recursively ensure all object types in JSON Schema have additionalProperties: false. + + OpenAI API requires all object types to explicitly set additionalProperties: false. + Note: Only GPT models need this parameter. Adding this parameter to other models may cause API calls to fail. + + Args: + schema: JSON Schema dictionary + + Returns: + Processed JSON Schema dictionary + """ + # Only GPT models need additionalProperties: false, other models return the original schema + if "gpt" not in self._model_name.lower(): + return schema + + if not isinstance(schema, dict): + return schema + + result = schema.copy() + + # Check if it's an object type: type: object or has properties field + if result.get("type") == "object" or "properties" in result: + result["additionalProperties"] = False + result.setdefault("type", "object") + result.setdefault("properties", {}) + + # Process all fields that may contain nested schemas + for key, value in result.items(): + if isinstance(value, dict): + result[key] = self._ensure_additional_properties_false(value) + elif isinstance(value, list): + result[key] = [ + self._ensure_additional_properties_false(item) if isinstance(item, dict) else item for item in value + ] + + return result + + def _build_response_format(self, config: GenerateContentConfig) -> Optional[Dict[str, Any]]: + """Build OpenAI response format from Google GenAI config. + + Args: + config: Google GenAI GenerateContentConfig object + + Returns: + OpenAI response format dictionary or None + """ + # Handle response_mime_type and response_schema + if config.response_mime_type == "application/json": + handled, response_format = self._adapter.build_response_format(config) + if handled: + return response_format + + if config.response_schema: + # response_schema must be pydantic.BaseModel + if not isinstance(config.response_schema, type(BaseModel)): + raise ValueError(f"{type(config.response_schema)} must be pydantic.BaseModel") + openai_schema = config.response_schema.model_json_schema() # type: ignore + openai_schema = self._ensure_additional_properties_false(openai_schema) + return { + "type": "json_schema", + "json_schema": { + "name": "response_schema", + "schema": openai_schema, + "strict": True + }, + } + elif config.response_json_schema: + # Use provided JSON schema directly + processed_schema = self._ensure_additional_properties_false(config.response_json_schema) + return { + "type": "json_schema", + "json_schema": { + "name": "response_schema", + "schema": processed_schema, + "strict": True + }, + } + else: + # Basic JSON mode + return {"type": "json_object"} + + return None + + def _extract_http_options(self, config: GenerateContentConfig) -> Dict[str, Any]: + """Extract HTTP options from config for OpenAI API calls. + + Args: + config: Google GenAI GenerateContentConfig object + + Returns: + Dictionary containing OpenAI-compatible HTTP options + """ + http_opts = {} + + if not config.http_options: + return http_opts + + http_options = config.http_options + + if http_options.headers: + # Process headers, invoking callables if present + processed_headers = {} + for key, value in http_options.headers.items(): + if callable(value): + processed_headers[key] = value() + else: + processed_headers[key] = value + http_opts["extra_headers"] = processed_headers + + if http_options.timeout is not None: + http_opts["timeout"] = http_options.timeout / 1000.0 + + if http_options.extra_body: + # Process extra_body, invoking callables if present + processed_extra_body = {} + for key, value in http_options.extra_body.items(): + if callable(value): + processed_extra_body[key] = value() + else: + processed_extra_body[key] = value + http_opts["extra_body"] = processed_extra_body + + return http_opts + + def _merge_configs(self, request_config: Optional[GenerateContentConfig]) -> GenerateContentConfig: + """Merge the default generate_content_config with the request config. + + The request config takes precedence over the default config. + If a field is set in the request config, it will override the default. + If a field is not set in the request config, the default value will be used. + + Args: + request_config: Config from the request (can be None) + + Returns: + Merged GenerateContentConfig + """ + # If no request config provided, use default config if available + if not request_config: + if self.generate_content_config: + return self.generate_content_config.model_copy(deep=True) + return GenerateContentConfig() + + # If no default config, return request config as is + if not self.generate_content_config: + return request_config + + # Get explicitly set fields from request_config using model_fields_set + # This is more reliable than model_dump(exclude_unset=True) for enum handling + request_set_fields = request_config.model_fields_set + + # Get explicitly set fields from default config + default_set_fields = self.generate_content_config.model_fields_set + + # Set default values on request_config for fields that are not already set + for field_name in default_set_fields: + if field_name not in request_set_fields: + # Only set if not already set in request_config + default_value = getattr(self.generate_content_config, field_name) + setattr(request_config, field_name, default_value) + # Update model_fields_set to reflect that this field is now set + request_config.model_fields_set.add(field_name) + + return request_config + + def _log_unsupported_config_options(self, config: GenerateContentConfig) -> None: + """Log warnings for configuration options that are not supported in OpenAI. + + Args: + config: Google GenAI GenerateContentConfig object + """ + unsupported_options = [] + + if config.top_k is not None: + unsupported_options.append("top_k") + if config.safety_settings: + unsupported_options.append("safety_settings") + if config.cached_content: + unsupported_options.append("cached_content") + if config.response_modalities: + unsupported_options.append("response_modalities") + if config.media_resolution: + unsupported_options.append("media_resolution") + if config.speech_config: + unsupported_options.append("speech_config") + if config.audio_timestamp: + unsupported_options.append("audio_timestamp") + if config.automatic_function_calling: + unsupported_options.append("automatic_function_calling") + + if unsupported_options: + logger.warning( + "The following configuration options are not supported in OpenAI models and will be ignored: %s", + ', '.join(unsupported_options)) + + @override + async def _generate_async_impl(self, + request: LlmRequest, + stream: bool = False, + ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + """Generate content asynchronously.""" + self.validate_request(request) + + # Merge default config with request config + merged_config = self._merge_configs(request.config) + + # Update request with merged config + request.config = merged_config + if (request.config and request.config.tools and self._adapter.requires_add_tools_to_prompt() + and not self.add_tools_to_prompt): + raise ValueError(f"{self._model_name} requires add_tools_to_prompt=True when tools are used.") + + # Prepare OpenAI API parameters + messages = self._format_messages(request) + + # Debug log the formatted messages to help with troubleshooting + logger.debug("Formatted messages for OpenAI API: %s", json.dumps(messages, indent=2)) + + api_params = { + ApiParamsKey.MODEL: self._model_name, + ApiParamsKey.MESSAGES: messages, + ApiParamsKey.STREAM: stream, + } + + # Add configuration parameters + try: + if request.config: + # Log warnings for unsupported configuration options + self._log_unsupported_config_options(request.config) + if request.config.max_output_tokens: + if self._adapter.use_max_tokens_only(): + api_params[ApiParamsKey.MAX_TOKENS] = request.config.max_output_tokens + else: + # Use max_completion_tokens for newer models (preferred), fallback to max_tokens + api_params[ApiParamsKey.MAX_COMPLETION_TOKENS] = request.config.max_output_tokens + # Keep max_tokens for backward compatibility (skip for gpt models) + if "gpt-5" not in self._model_name.lower(): + api_params[ApiParamsKey.MAX_TOKENS] = request.config.max_output_tokens + if request.config.temperature is not None: + api_params[ApiParamsKey.TEMPERATURE] = request.config.temperature + if request.config.top_p is not None: + api_params[ApiParamsKey.TOP_P] = request.config.top_p + if request.config.stop_sequences: + api_params[ApiParamsKey.STOP] = request.config.stop_sequences + + # Additional OpenAI-specific parameters + if (request.config.frequency_penalty is not None + and not self._adapter.should_skip_config_param("frequency_penalty")): + api_params[ApiParamsKey.FREQUENCY_PENALTY] = request.config.frequency_penalty + if (request.config.presence_penalty is not None + and not self._adapter.should_skip_config_param("presence_penalty")): + api_params[ApiParamsKey.PRESENCE_PENALTY] = request.config.presence_penalty + if request.config.seed is not None and not self._adapter.should_skip_config_param("seed"): + api_params[ApiParamsKey.SEED] = request.config.seed + + # Handle candidate count (maps to OpenAI's 'n' parameter) + if (request.config.candidate_count is not None and request.config.candidate_count > 0 + and not self._adapter.should_skip_config_param("candidate_count")): + api_params[ApiParamsKey.N] = request.config.candidate_count + + # Handle logprobs configuration + if request.config.response_logprobs is not None: + api_params[ApiParamsKey.LOGPROBS] = request.config.response_logprobs + if request.config.logprobs is not None and request.config.logprobs > 0: + api_params[ApiParamsKey.TOP_LOGPROBS] = request.config.logprobs + + # Currently, not support response_format for OpenAI + # Handle response format for structured output + response_format = self._build_response_format(request.config) + if response_format: + api_params[ApiParamsKey.RESPONSE_FORMAT] = response_format + + # Handle tools - convert from Google GenAI format to OpenAI format + if not self.add_tools_to_prompt and request.config.tools: + converted_tools = self._convert_tools_to_openai_format(request.config.tools) # type: ignore + if converted_tools: + api_params[ApiParamsKey.TOOLS] = converted_tools + api_params[ApiParamsKey.TOOL_CHOICE] = "auto" + except Exception as ex: # pylint: disable=broad-except + logger.error("Error in OpenAI API parameters: %s", ex, exc_info=True) + raise ex + + cache_config = self._resolve_prompt_cache_config(ctx) + if cache_config: + if cache_config.prompt_cache_key: + api_params[ApiParamsKey.PROMPT_CACHE_KEY] = cache_config.prompt_cache_key + if cache_config.ttl: + api_params[ApiParamsKey.PROMPT_CACHE_RETENTION] = cache_config.ttl + + # Extract HTTP options for API calls + http_options = {} + if request.config: + http_options = self._extract_http_options(request.config) + # set thinking params + self._set_thinking(request, http_options) + + if stream: + async for response in self._generate_stream(api_params, request, http_options, ctx): + yield response + else: + response = await self._generate_single(api_params, request, http_options, ctx) + yield response + + async def _generate_stream(self, + api_params: Dict, + request: LlmRequest, + http_options: Dict[str, Any] | None = None, + ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + """Generate streaming responses.""" + if http_options is None: + http_options = {} + thought_content = "" + accumulated_content = "" + visible_content = "" + last_usage = None + accumulated_tool_calls: list[dict] = [] + is_thinking = False # Track whether we're currently in thinking mode + response_id: str | None = None # Track response ID from API + + # For streaming tool call arguments - get the set of tool names that should stream + streaming_tool_names = getattr(request, 'streaming_tool_names', None) or set() + + # Create tool prompt instance for streaming if needed + tool_prompt = None + streaming_text_filter_state = None + if self.add_tools_to_prompt: + tool_prompt = self._create_tool_prompt() + streaming_text_filter_state = { + "content": self._adapter.create_streaming_text_filter_state(), + "reasoning": self._adapter.create_streaming_text_filter_state(), + } + + api_params[ApiParamsKey.STREAM_OPTS] = {ApiParamsKey.INCLUDE_USAGE: True} + + client = self._create_async_client() + logger.debug("openai invoke with params: %s", api_params) + try: + response = await client.chat.completions.create(**api_params, **http_options) + if response is None: + raise ValueError("Empty response from API") + + async for chunk in response: + if chunk is None: + continue + + chunk_dict: dict = chunk.model_dump() + logger.debug("🔥 RAW LLM CHUNK: %s", json.dumps(chunk_dict, ensure_ascii=False)) + + # Capture response ID from chunk (only set once from first chunk that has it) + if response_id is None and chunk_dict.get("id"): + response_id = chunk_dict.get("id") + + # Check for thinking events first + if self._is_thinking_event(chunk_dict): + thinking_state = self._get_thinking_state(chunk_dict) + if thinking_state == 0: + # Start thinking + is_thinking = True + elif thinking_state == 2: + # End thinking + is_thinking = False + # Handle thinking events - these are metadata about thinking state + # We can log them but don't need to yield them as content + continue + + # Verify if the chunk contains valid text content + if not self._verify_text_content_in_delta_response(chunk_dict): + # Process chunk without content (this is where tool calls are streamed) + _, usage, delta_arguments = self._process_chunk_without_content(chunk_dict, accumulated_tool_calls) + + if usage: + last_usage = usage + + # If streaming tool call arguments is enabled, yield partial tool call events + # delta_arguments being non-empty means tool calls were processed + if streaming_tool_names and delta_arguments and accumulated_tool_calls: + # Yield streaming tool call event with delta arguments + # Only for tools in streaming_tool_names + streaming_event = self._create_streaming_tool_call_response(accumulated_tool_calls, + delta_arguments, + streaming_tool_names) + if streaming_event: + yield streaming_event + + continue + + # Process chunk with valid content + choices = chunk_dict.get(const.CHOICES, []) + if not choices: + continue # Skip if no choices available + choice: dict[str, dict] = choices[0] + delta = choice[const.DELTA] + + # Handle reasoning content (thinking content) first + if delta.get(const.REASONING_CONTENT): + reasoning_content = delta.get(const.REASONING_CONTENT) + if reasoning_content is not None: + partial_text = reasoning_content + if (tool_prompt and streaming_text_filter_state is not None + and self._adapter.should_filter_reasoning_text()): + reasoning_filter_state = streaming_text_filter_state["reasoning"] + partial_text = self._adapter.filter_streaming_text(reasoning_content, + reasoning_filter_state) + if not partial_text: + continue + + # Reasoning content is always thinking content + thought_content += partial_text + + # Set thought flag to True for reasoning content + content_part = Part.from_text(text=partial_text) + content_part.thought = True + + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: chunk_dict}) + + # Handle regular content + if delta.get(const.CONTENT): + content = delta.get(const.CONTENT) + if content is not None: + if not is_thinking: + accumulated_content += content + else: + thought_content += content + + partial_text = content + if tool_prompt and streaming_text_filter_state is not None: + content_filter_state = streaming_text_filter_state["content"] + partial_text = self._adapter.filter_streaming_text(content, content_filter_state) + if not partial_text: + continue + if not is_thinking: + visible_content += partial_text + + # Set thought flag based on current thinking state + content_part = Part.from_text(text=partial_text) + content_part.thought = is_thinking + + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: chunk_dict}) + + # Handle usage + usage = self._process_usage(chunk_dict) + if usage: + last_usage = usage + + if tool_prompt and streaming_text_filter_state is not None: + if self._adapter.should_filter_reasoning_text(): + flushed_reasoning_text = self._adapter.flush_streaming_text( + streaming_text_filter_state["reasoning"]) + if flushed_reasoning_text: + thought_content += flushed_reasoning_text + content_part = Part.from_text(text=flushed_reasoning_text) + content_part.thought = True + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={"stream_filter_flushed": "reasoning"}) + + flushed_content_text = self._adapter.flush_streaming_text(streaming_text_filter_state["content"]) + if flushed_content_text: + if not is_thinking: + visible_content += flushed_content_text + content_part = Part.from_text(text=flushed_content_text) + content_part.thought = is_thinking + partial_content = Content(parts=[content_part], role=const.MODEL) + yield LlmResponse(content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={"stream_filter_flushed": "content"}) + + # Yield final complete response + final_content = None + + parts = [] + + if thought_content: + logger.debug("Final accumulated thought content: %s...", thought_content[:200]) + content_part = Part.from_text(text=thought_content) + content_part.thought = True + parts.append(content_part) + + # Parse function calls from final accumulated content if add_tools_to_prompt is enabled + complete_tool_calls = self._create_complete_tool_calls(accumulated_tool_calls) + if tool_prompt and accumulated_content and not complete_tool_calls: + try: + parsed_function_calls = self._adapter.parse_tool_prompt_function_calls( + accumulated_content, tool_prompt) + if parsed_function_calls: + # Convert FunctionCall objects to ToolCall objects + complete_tool_calls = [] + for func_call in parsed_function_calls: + tool_call = ToolCall(id=f"call_{uuid.uuid4().hex[:24]}", + name=func_call.name, + arguments=func_call.args) + complete_tool_calls.append(tool_call) + logger.debug("Parsed %s function calls from final accumulated content", + len(complete_tool_calls)) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to parse function calls from final accumulated content: %s", ex) + + # Add text content if present + final_text_content = accumulated_content + if complete_tool_calls and self._adapter.should_suppress_tool_prompt_text(): + final_text_content = visible_content + if final_text_content: + logger.debug("Final accumulated regular content: %s...", final_text_content[:200]) + content_part = Part.from_text(text=final_text_content) + content_part.thought = False # Final accumulated content represents the answer, not thinking + parts.append(content_part) + + if complete_tool_calls: + for tool_call in complete_tool_calls: + # Create Part with function_call using the from_function_call method + function_part = Part.from_function_call(name=tool_call.name, args=tool_call.arguments) + # Set the id if available + if tool_call.id: + function_part.function_call.id = tool_call.id # type: ignore + self._set_part_thought_signature(function_part, tool_call.thought_signature) + parts.append(function_part) + + # Create final content with parts + if parts: + final_content = Content(parts=parts, role=const.MODEL) + + # Convert usage to the expected format for LlmResponse + final_usage = None + if last_usage: + # Create a compatible usage metadata object + final_usage = last_usage # Use the existing usage object for now + + yield LlmResponse( + content=final_content, + usage_metadata=final_usage, + partial=False, + response_id=response_id, + custom_metadata={"stream_complete": True}, + ) + finally: + await self._http_client_provider.close_http_client(client) diff --git a/trpc_agent_sdk/models/_registry.py b/trpc_agent_sdk/models/_registry.py new file mode 100644 index 000000000..f8a4f83a4 --- /dev/null +++ b/trpc_agent_sdk/models/_registry.py @@ -0,0 +1,136 @@ +# 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. +"""Model registry implementation module. + +This module provides the ModelRegistry class which manages registration and +resolution of model implementations. It supports: +- Model class registration +- Model name pattern matching +- Model instance creation +- Decorator-based registration +""" + +import re +from functools import lru_cache +from typing import Callable +from typing import Dict +from typing import List +from typing import Type +from typing_extensions import override + +from ._llm_model import LLMModel + + +class ModelRegistry: + """Registry for model implementations.""" + + _registry: Dict[str, Type[LLMModel]] = {} + + @classmethod + def _register_class(cls, model_class: Type[LLMModel]) -> None: + """Internal method to register a model class. + + Args: + model_class: The model class to register + """ + for pattern in model_class.supported_models(): + cls._registry[pattern] = model_class + + @classmethod + def register(cls, model_class: Type[LLMModel]) -> Type[LLMModel]: + """Register a model class (can be used as decorator or function). + + Args: + model_class: The model class to register + + Returns: + The same model class (for decorator usage) + """ + cls._register_class(model_class) + return model_class + + @classmethod + @lru_cache(maxsize=128) + def resolve(cls, model_name: str) -> Type[LLMModel]: + """Resolve model name to model class. + + Args: + model_name: The model name to resolve + + Returns: + The model class + + Raises: + ValueError: If model is not found + """ + for pattern, model_class in cls._registry.items(): + if re.match(pattern, model_name.lower()): + return model_class + + raise ValueError(f"No model implementation found for: {model_name}") + + @classmethod + def create_model(cls, model_name: str, **kwargs) -> LLMModel: + """Create a model instance. + + Args: + model_name: The model name + **kwargs: Additional configuration + + Returns: + Model instance + """ + model_class = cls.resolve(model_name) + return model_class(model_name, **kwargs) + + @classmethod + def list_supported_models(cls) -> List[str]: + """List all supported model patterns.""" + return list(cls._registry.keys()) + + @classmethod + def list_registered_classes(cls) -> List[Type[LLMModel]]: + """List all registered model classes.""" + return list(set(cls._registry.values())) + + +def register_model(model_name: str, supported_models: List[str]) -> Callable: + """Decorator to register a model class. + + Args: + model_name: The display name for this model implementation + supported_models: Regex pattern to match model names + + Returns: + Decorator function + + Usage: + @register_model(model_name="OpenAIModel", supported_models=[r"gpt-.*"]) + class OpenAIModel(BaseModel): + ... + """ + + def decorator(model_class: Type[LLMModel]) -> Type[LLMModel]: + # Create a new class that inherits from the decorated class + # This ensures the abstract method is properly implemented + class _RegisteredModel(model_class): + + @override + @classmethod + def supported_models(cls) -> List[str]: + return supported_models + + # Copy over class attributes + _RegisteredModel.__name__ = model_class.__name__ + _RegisteredModel.__qualname__ = model_class.__qualname__ + _RegisteredModel.__module__ = model_class.__module__ + _RegisteredModel._model_display_name = model_name + + # Register the new model class + ModelRegistry._register_class(_RegisteredModel) + return _RegisteredModel + + return decorator diff --git a/trpc_agent_sdk/models/_retry.py b/trpc_agent_sdk/models/_retry.py new file mode 100644 index 000000000..97d45aba9 --- /dev/null +++ b/trpc_agent_sdk/models/_retry.py @@ -0,0 +1,207 @@ +# 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. +"""Provider-agnostic model retry execution utilities.""" + +from __future__ import annotations + +import asyncio +import email.utils +import random +import time +from collections.abc import AsyncGenerator +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any +from typing import Optional + +from trpc_agent_sdk.configs import ExponentialBackoffConfig +from trpc_agent_sdk.configs import ModelRetryConfig +from trpc_agent_sdk.log import logger + +from ._llm_response import LlmResponse + +_MAX_RETRY_AFTER_SECONDS = 60.0 + + +@dataclass(frozen=True) +class ModelRetryInfo: + """Provider retry decision and optional server-suggested delay. + + should_retry indicates whether the failed model call is safe to retry. + retry_after is the HTTP-standard Retry-After value, expressed in seconds. + When the server provides it, SDK-managed retry should respect this delay. + """ + + should_retry: bool + retry_after: Optional[float] = None + + +def _extract_status_code(ex: Exception) -> Optional[int]: + status_code = getattr(ex, "status_code", None) + if isinstance(status_code, int): + return status_code + if isinstance(status_code, str): + try: + return int(status_code) + except ValueError: + return None + + return None + + +def _extract_headers(ex: Exception) -> Any: + # LiteLLM attaches headers to normalized exceptions. + litellm_headers = getattr(ex, "litellm_response_headers", None) + if litellm_headers is not None: + return litellm_headers + + response = getattr(ex, "response", None) + response_headers = getattr(response, "headers", None) + if response_headers is not None: + return response_headers + + return getattr(ex, "headers", None) + + +def _get_header(headers: Any, name: str) -> Any: + get_header = getattr(headers, "get", None) + if get_header is None: + return None + value = get_header(name) + if value is not None: + return value + return get_header(name.lower()) + + +def _retry_after_from_headers(headers: Any) -> Optional[float]: + retry_after_ms = _get_header(headers, "retry-after-ms") + if retry_after_ms is not None: + try: + return float(retry_after_ms) / 1000.0 + except (TypeError, ValueError): + pass + + retry_after = _get_header(headers, "retry-after") + if retry_after is None: + return None + try: + return float(retry_after) + except (TypeError, ValueError): + parsed = email.utils.parsedate_tz(retry_after) + if parsed is None: + return None + return email.utils.mktime_tz(parsed) - time.time() + + +def _should_retry_from_headers_or_status( + ex: Exception, + is_retriable_status_code: Callable[[int], Optional[bool]], +) -> Optional[bool]: + headers = _extract_headers(ex) + # x-should-retry is not a standard header, but it's supported by both the OpenAI and Anthropic api. + should_retry = _get_header(headers, "x-should-retry") + if isinstance(should_retry, str): + normalized = should_retry.strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + + status_code = _extract_status_code(ex) + if status_code is None: + return None + return is_retriable_status_code(status_code) + + +def _model_retry_info_from_exception( + ex: Exception, + is_retriable_status_code: Callable[[int], Optional[bool]], + is_retriable_exception: Callable[[Exception], bool], +) -> ModelRetryInfo: + decision = _should_retry_from_headers_or_status(ex, is_retriable_status_code) + if decision is None: + decision = is_retriable_exception(ex) + return ModelRetryInfo(should_retry=decision, retry_after=_retry_after_from_headers(_extract_headers(ex))) + + +def _compute_exponential_backoff( + config: ExponentialBackoffConfig, + attempt: int, + retry_after: Optional[float], +) -> float: + if retry_after is not None and 0 < retry_after <= _MAX_RETRY_AFTER_SECONDS: + return float(retry_after) + + delay = config.initial_backoff * (config.multiplier**attempt) + delay = min(delay, config.max_backoff) + if config.jitter: + return random.uniform(0.0, delay) + return delay + + +def _build_error_response(ex: Exception, error_code: str) -> LlmResponse: + return LlmResponse( + content=None, + error_code=error_code, + error_message=str(ex), + custom_metadata={"error": str(ex)}, + ) + + +async def retry_model_call( + call_model: Callable[[], AsyncGenerator[LlmResponse, None]], + retry_config: Optional[ModelRetryConfig], + *, + error_code: str = "API_ERROR", + get_retry_info: Callable[[Exception], ModelRetryInfo] | None = None, +) -> AsyncGenerator[LlmResponse, None]: + """Execute a model call with SDK-managed retry. + + Retries only when an attempt raises before emitting user-visible content. Once + content has been yielded, subsequent failures are converted to a final error + response and surfaced without replaying the request. + """ + attempt = 0 + + while True: + produced_content = False + attempt_stream = call_model() + try: + async for response in attempt_stream: + if response.has_content(): + produced_content = True + yield response + return + except Exception as ex: # pylint: disable=broad-except + if retry_config is None or produced_content: + yield _build_error_response(ex, error_code) + return + + if attempt >= retry_config.num_retries: + yield _build_error_response(ex, error_code) + return + + if get_retry_info is None: + yield _build_error_response(ex, error_code) + return + + retry_info = get_retry_info(ex) + if not retry_info.should_retry: + yield _build_error_response(ex, error_code) + return + + delay = _compute_exponential_backoff(retry_config.backoff, attempt, retry_info.retry_after) + logger.warning( + "Model call failed (exception=%s); retrying in %.2fs (attempt %d/%d).", + type(ex).__name__, + delay, + attempt + 1, + retry_config.num_retries, + ) + await asyncio.sleep(delay) + attempt += 1 + finally: + await attempt_stream.aclose() diff --git a/trpc_agent_sdk/models/openai_adapter/__init__.py b/trpc_agent_sdk/models/openai_adapter/__init__.py new file mode 100644 index 000000000..b41e7c154 --- /dev/null +++ b/trpc_agent_sdk/models/openai_adapter/__init__.py @@ -0,0 +1,36 @@ +# 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. +"""Adapters for OpenAI-compatible model providers.""" + +from __future__ import annotations + +from typing import Optional + +from ._base import DefaultOpenAIAdapter +from ._base import OpenAIAdapter +from ._base import ToolPromptTextFilterMixin +from ._deepseek import DeepSeekAdapter +from ._hunyuan import HunyuanHy3PreviewAdapter + + +def get_openai_adapter(model_name: str, base_url: Optional[str] = None) -> OpenAIAdapter: + """Return the provider adapter for an OpenAI-compatible model.""" + model_name_lower = model_name.lower() + if model_name_lower == "hy3-preview": + return HunyuanHy3PreviewAdapter(model_name=model_name, base_url=base_url) + if model_name_lower.startswith("deepseek-"): + return DeepSeekAdapter(model_name=model_name, base_url=base_url) + return DefaultOpenAIAdapter(model_name=model_name, base_url=base_url) + + +__all__ = [ + "DefaultOpenAIAdapter", + "DeepSeekAdapter", + "HunyuanHy3PreviewAdapter", + "OpenAIAdapter", + "ToolPromptTextFilterMixin", + "get_openai_adapter", +] diff --git a/trpc_agent_sdk/models/openai_adapter/_base.py b/trpc_agent_sdk/models/openai_adapter/_base.py new file mode 100644 index 000000000..8d042ad9f --- /dev/null +++ b/trpc_agent_sdk/models/openai_adapter/_base.py @@ -0,0 +1,183 @@ +# 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. +"""Base adapter for OpenAI-compatible model provider differences.""" + +from __future__ import annotations + +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.types import FunctionCall + +from .. import _constants as const + +_TOOL_PROMPT_MARKERS = ( + "", + "", + "", + "", + "", + "", + "", + "", +} +_TOOL_PROMPT_MARKER_LOOKBEHIND = max( + max(len(marker) for marker in _TOOL_PROMPT_MARKERS), + max(len(marker) for marker in _TOOL_PROMPT_MARKER_ENDS.values()), +) - 1 + + +class OpenAIAdapter: + """Adapter hook points for provider-specific OpenAI-compatible behavior.""" + + def __init__(self, model_name: str, base_url: Optional[str] = None): + self.model_name = model_name + self.base_url = base_url + + def use_max_tokens_only(self) -> bool: + """Whether max_output_tokens should map only to max_tokens.""" + return False + + def should_skip_config_param(self, param_name: str) -> bool: + """Whether a GenerateContentConfig field should be skipped for this provider.""" + return False + + def should_include_thought_signature(self) -> bool: + """Whether tool call history should include thought_signature.""" + return True + + def should_backfill_reasoning_content(self, role: str, message: dict[str, Any]) -> bool: + """Whether assistant history should include an empty reasoning_content field.""" + return False + + def build_response_format(self, config: Any) -> tuple[bool, Optional[dict[str, Any]]]: + """Return provider-specific response_format. + + The first tuple item indicates whether the adapter handled the config. + """ + return False, None + + def apply_thinking(self, request: Any, http_options: dict[str, Any]) -> bool: + """Apply provider-specific thinking options. + + Returns True when the adapter handled thinking and the default OpenAI + thinking mapping should be skipped. + """ + return False + + def parse_tool_prompt_function_calls(self, content: str, tool_prompt: Any) -> List[FunctionCall]: + """Parse text-form tool calls emitted by a provider.""" + return tool_prompt.parse_function(content) + + def requires_add_tools_to_prompt(self) -> bool: + """Whether this adapter requires ToolPrompt mode when tools are used.""" + return False + + def should_suppress_tool_prompt_text(self) -> bool: + """Whether parsed text-form tool calls should be hidden from final text.""" + return False + + def should_filter_reasoning_text(self) -> bool: + """Whether ToolPrompt filtering should also apply to reasoning_content.""" + return False + + def create_streaming_text_filter_state(self) -> dict[str, Any]: + """Create per-stream state for filtering provider-specific text chunks.""" + return {} + + def filter_streaming_text(self, text: str, state: dict[str, Any]) -> str: + """Filter a streaming text chunk before yielding it to users.""" + return text + + def flush_streaming_text(self, state: dict[str, Any]) -> str: + """Flush any buffered streaming text after the stream ends.""" + return "" + + +class DefaultOpenAIAdapter(OpenAIAdapter): + """Default OpenAI-compatible adapter with no provider overrides.""" + + pass + + +class ToolPromptTextFilterMixin: + """Opt-in filtering for models that emit ToolPrompt XML as streamed text.""" + + def should_suppress_tool_prompt_text(self) -> bool: + return True + + def create_streaming_text_filter_state(self) -> dict[str, Any]: + return { + "buffer": "", + "suppress": False, + "suppress_until": "", + } + + def filter_streaming_text(self, text: str, state: dict[str, Any]) -> str: + if state.get("suppress"): + buffer = f"{state.get('buffer', '')}{text}" + suppress_until = state.get("suppress_until") or "" + marker_start = buffer.find(suppress_until) if suppress_until else -1 + if marker_start < 0: + state["buffer"] = buffer[-_TOOL_PROMPT_MARKER_LOOKBEHIND:] + return "" + + resume_at = marker_start + len(suppress_until) + state["buffer"] = "" + state["suppress"] = False + state["suppress_until"] = "" + return self.filter_streaming_text(buffer[resume_at:], state) + + buffer = f"{state.get('buffer', '')}{text}" + marker_start, marker = self._find_first_tool_prompt_marker(buffer) + if marker: + state["buffer"] = "" + state["suppress"] = True + state["suppress_until"] = _TOOL_PROMPT_MARKER_ENDS[marker] + return buffer[:marker_start] + self.filter_streaming_text(buffer[marker_start:], state) + + if len(buffer) <= _TOOL_PROMPT_MARKER_LOOKBEHIND: + state["buffer"] = buffer + return "" + + split_at = len(buffer) - _TOOL_PROMPT_MARKER_LOOKBEHIND + state["buffer"] = buffer[split_at:] + return buffer[:split_at] + + def flush_streaming_text(self, state: dict[str, Any]) -> str: + if state.get("suppress"): + return "" + + buffer = state.get("buffer", "") + state["buffer"] = "" + marker_start, marker = self._find_first_tool_prompt_marker(buffer) + if marker: + state["suppress"] = True + state["suppress_until"] = _TOOL_PROMPT_MARKER_ENDS[marker] + return buffer[:marker_start] + return buffer + + def _find_first_tool_prompt_marker(self, text: str) -> tuple[int, Optional[str]]: + marker_positions = [(text.find(marker), marker) for marker in _TOOL_PROMPT_MARKERS if marker in text] + if not marker_positions: + return -1, None + return min(marker_positions, key=lambda item: item[0]) + + +def has_reasoning_content(message: dict[str, Any]) -> bool: + """Return whether message already includes reasoning_content.""" + return const.REASONING_CONTENT in message diff --git a/trpc_agent_sdk/models/openai_adapter/_deepseek.py b/trpc_agent_sdk/models/openai_adapter/_deepseek.py new file mode 100644 index 000000000..64c82baec --- /dev/null +++ b/trpc_agent_sdk/models/openai_adapter/_deepseek.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 Apache-2.0. +"""DeepSeek adapter for OpenAI-compatible chat completions.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + +from trpc_agent_sdk.log import logger + +from .. import _constants as const +from ._base import OpenAIAdapter +from ._base import has_reasoning_content + + +class DeepSeekAdapter(OpenAIAdapter): + """Provider-specific behavior for DeepSeek's OpenAI-compatible API.""" + + def __init__(self, model_name: str, base_url: Optional[str] = None): + super().__init__(model_name=model_name, base_url=base_url) + self._model_name_lower = model_name.lower() + + def is_v4_model(self) -> bool: + """Return whether the current model uses DeepSeek v4 chat completions.""" + return self._model_name_lower.startswith("deepseek-v4-") + + def use_max_tokens_only(self) -> bool: + return True + + def should_skip_config_param(self, param_name: str) -> bool: + return param_name in { + "frequency_penalty", + "presence_penalty", + "seed", + "candidate_count", + } + + def should_include_thought_signature(self) -> bool: + return False + + def should_backfill_reasoning_content(self, role: str, message: dict[str, Any]) -> bool: + if not self.is_v4_model() or role != const.ASSISTANT: + return False + if has_reasoning_content(message): + return False + return bool(message.get(const.CONTENT) or message.get(const.TOOL_CALLS)) + + def build_response_format(self, config: Any) -> tuple[bool, Optional[dict[str, Any]]]: + if config.response_mime_type != "application/json": + return False, None + if config.response_schema or config.response_json_schema: + logger.warning("DeepSeek only supports JSON object response_format; response schema is ignored.") + return True, {"type": "json_object"} + + def apply_thinking(self, request: Any, http_options: dict[str, Any]) -> bool: + if not self.is_v4_model(): + return False + if not request.config or not request.config.thinking_config: + return False + + thinking_config = request.config.thinking_config + if "extra_body" not in http_options: + http_options["extra_body"] = {} + processed_extra_body = http_options["extra_body"] + thinking_body = dict(processed_extra_body.get("thinking") or {}) + + if thinking_config.include_thoughts and thinking_config.thinking_budget != 0: + thinking_body["type"] = "enabled" + thinking_body.setdefault( + "reasoning_effort", + "max" if thinking_config.thinking_budget and thinking_config.thinking_budget > 0 else "high", + ) + else: + thinking_body["type"] = "disabled" + + processed_extra_body["thinking"] = thinking_body + return True diff --git a/trpc_agent_sdk/models/openai_adapter/_hunyuan.py b/trpc_agent_sdk/models/openai_adapter/_hunyuan.py new file mode 100644 index 000000000..4451a6756 --- /dev/null +++ b/trpc_agent_sdk/models/openai_adapter/_hunyuan.py @@ -0,0 +1,84 @@ +# 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. +"""Hunyuan adapter for OpenAI-compatible chat completions.""" + +from __future__ import annotations + +import json +import re +from typing import Any +from typing import List +from typing import Optional + +from trpc_agent_sdk.types import FunctionCall + +from ._base import OpenAIAdapter +from ._base import ToolPromptTextFilterMixin + + +class HunyuanHy3PreviewAdapter(ToolPromptTextFilterMixin, OpenAIAdapter): + """Provider-specific behavior for the hy3-preview model.""" + + def __init__(self, model_name: str, base_url: Optional[str] = None): + super().__init__(model_name=model_name, base_url=base_url) + + def parse_tool_prompt_function_calls(self, content: str, tool_prompt: Any) -> List[FunctionCall]: + function_calls = self._parse_hunyuan_tool_calls(content) + if function_calls: + return function_calls + return tool_prompt.parse_function(content) + + def requires_add_tools_to_prompt(self) -> bool: + return True + + def should_filter_reasoning_text(self) -> bool: + return True + + def _parse_hunyuan_tool_calls(self, content: str) -> List[FunctionCall]: + function_calls = [] + matches = re.findall(r"(.*?)", content, re.DOTALL) + + for match in matches: + if "" not in match: + continue + + tool_name, params_content = match.split("", 1) + args = self._parse_hunyuan_tool_args(params_content) + function_calls.append(FunctionCall(name=tool_name.strip(), args=args)) + + return function_calls + + def _parse_hunyuan_tool_args(self, params_content: str) -> dict[str, Any]: + args: dict[str, Any] = {} + param_matches = re.findall( + r"(.*?)\s*(.*?)", + params_content, + re.DOTALL, + ) + if param_matches: + for key, value in param_matches: + args[key.strip()] = self._parse_arg_value(value.strip()) + return args + + params_content = params_content.strip() + if not params_content: + return args + + parsed_value = self._parse_arg_value(params_content) + if isinstance(parsed_value, dict): + return parsed_value + return {"value": parsed_value} + + def _parse_arg_value(self, value: str) -> Any: + # Keep STRICT json.loads here. Hunyuan's tags often contain + # plain text (e.g. "Beijing", "2025-01-01"). json_repair would silently + # coerce such plain text into "" and skip the fallback branch below, + # corrupting tool arguments. Real JSON-shaped values still parse, and + # the fallback preserves the original string for everything else. + try: + return json.loads(value) + except json.JSONDecodeError: + return value diff --git a/trpc_agent_sdk/models/tool_prompt/__init__.py b/trpc_agent_sdk/models/tool_prompt/__init__.py new file mode 100644 index 000000000..d4c0d3968 --- /dev/null +++ b/trpc_agent_sdk/models/tool_prompt/__init__.py @@ -0,0 +1,26 @@ +# 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. +"""Tool prompt module for TRPC Agent framework.""" + +from ._base import ToolPrompt +from ._factory import ToolPromptFactory +from ._factory import get_factory +from ._factory import initialize +from ._factory import initialize as initialize_factory +from ._json import JsonToolPrompt +from ._xml import XmlToolPrompt + +__all__ = [ + "ToolPrompt", + "ToolPromptFactory", + "get_factory", + "initialize", + "initialize_factory", + "JsonToolPrompt", + "XmlToolPrompt", +] + +initialize_factory() diff --git a/trpc_agent_sdk/models/tool_prompt/_base.py b/trpc_agent_sdk/models/tool_prompt/_base.py new file mode 100644 index 000000000..6b87d17fb --- /dev/null +++ b/trpc_agent_sdk/models/tool_prompt/_base.py @@ -0,0 +1,52 @@ +# 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. +"""Base tool prompt class for TRPC Agent framework.""" + +from abc import ABC +from abc import abstractmethod +from typing import List + +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import Tool + + +class ToolPrompt(ABC): + """Abstract base class for tool prompt implementations. + + This class defines the interface for converting tools to prompt text + and parsing function calls from model responses. + """ + + def __init__(self): + """Initialize the tool prompt.""" + pass + + @abstractmethod + def build_prompt(self, tools: List[Tool]) -> str: + """Build a prompt string from a list of tools. + + Args: + tools: List of Tool objects to convert to prompt text + + Returns: + String representation of tools for inclusion in system prompt + """ + pass + + @abstractmethod + def parse_function(self, content: str) -> List[FunctionCall]: + """Parse function calls from complete content. + + Args: + content: Complete content string containing function calls + + Returns: + List of FunctionCall objects parsed from content + + Raises: + ValueError: If content cannot be parsed as function calls + """ + pass diff --git a/trpc_agent_sdk/models/tool_prompt/_factory.py b/trpc_agent_sdk/models/tool_prompt/_factory.py new file mode 100644 index 000000000..6c0f25923 --- /dev/null +++ b/trpc_agent_sdk/models/tool_prompt/_factory.py @@ -0,0 +1,85 @@ +# 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. +"""Tool prompt factory for TRPC Agent framework.""" + +from typing import Dict +from typing import Type + +from ._base import ToolPrompt + + +class ToolPromptFactory: + """Factory for creating tool prompt implementations.""" + + def __init__(self): + """Initialize the factory.""" + self._registry: Dict[str, Type[ToolPrompt]] = {} + + def register(self, name: str, tool_prompt_class: Type[ToolPrompt]) -> None: + """Register a tool prompt class with a name. + + Args: + name: Name to register the tool prompt class with + tool_prompt_class: ToolPrompt class to register + + Note: + If the name is duplicated, keeps the latest registration. + """ + self._registry[name] = tool_prompt_class + + def create(self, name: str) -> ToolPrompt: + """Create a tool prompt instance by name. + + Args: + name: Name of the tool prompt to create + + Returns: + ToolPrompt instance + + Raises: + ValueError: If name is not registered + """ + if name not in self._registry: + raise ValueError(f"Tool prompt '{name}' is not registered. Available: {list(self._registry.keys())}") + + tool_prompt_class = self._registry[name] + return tool_prompt_class() + + +# Global factory instance +_factory: ToolPromptFactory = None + + +def initialize() -> None: + """Initialize the factory and register built-in tool prompts. + + This function will be called when the OpenAIModel is imported. + It registers JsonToolPrompt and XmlToolPrompt. + """ + global _factory # pylint: disable=invalid-name + if _factory is None: + _factory = ToolPromptFactory() + + # Import and register built-in tool prompts + from ._json import JsonToolPrompt + from ._xml import XmlToolPrompt + + _factory.register("json", JsonToolPrompt) + _factory.register("xml", XmlToolPrompt) + + +def get_factory() -> ToolPromptFactory: + """Get the initialized factory. + + Returns: + ToolPromptFactory instance + + Raises: + RuntimeError: If factory is not initialized + """ + if _factory is None: + raise RuntimeError("Factory is not initialized. Call initialize() first.") + return _factory diff --git a/trpc_agent_sdk/models/tool_prompt/_json.py b/trpc_agent_sdk/models/tool_prompt/_json.py new file mode 100644 index 000000000..8462bdd92 --- /dev/null +++ b/trpc_agent_sdk/models/tool_prompt/_json.py @@ -0,0 +1,223 @@ +# 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. +"""JSON tool prompt class for TRPC Agent framework.""" + +import json +import re +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair + +from ._base import ToolPrompt + + +class JsonToolPrompt(ToolPrompt): + """JSON tool prompt implementation based on function calling format.""" + + def __init__(self): + """Initialize the JSON tool prompt.""" + super().__init__() + + @override + def build_prompt(self, tools: List[Tool]) -> str: + """Build JSON tool prompt from tools. + + Args: + tools: List of Tool objects to convert to JSON prompt + + Returns: + JSON-formatted tool prompt string + """ + if not tools: + return "" + + function_descriptions = [] + + for tool in tools: + if tool.function_declarations: + for func_decl in tool.function_declarations: + func_desc = self._format_function_description(func_decl) + function_descriptions.append(func_desc) + + if not function_descriptions: + return "" + + functions_json = json.dumps(function_descriptions, indent=2, ensure_ascii=False) + + prompt = ("Produce JSON OUTPUT ONLY! Adhere to this format" + '{"name": "function_name", "arguments":{"argument_name": "argument_value"}}\n' + "When you NOT call function, please DO NOT generate json code block.\n" + f"The following functions are available to you:\n{functions_json}") + + return prompt + + def _format_function_description(self, func_decl) -> Dict[str, Any]: + """Format a function declaration as JSON description. + + Args: + func_decl: Function declaration object + + Returns: + Dictionary representing the function description + """ + description = { + "name": func_decl.name or "", + "description": func_decl.description or "", + } + + if func_decl.parameters: + description["parameters"] = self._convert_schema_to_json(func_decl.parameters) + else: + description["parameters"] = {"type": "object", "properties": {}} + + return description + + def _convert_schema_to_json(self, schema) -> Dict[str, Any]: + """Convert schema to JSON format. + + Args: + schema: Schema object to convert + + Returns: + Dictionary representing the schema in JSON format + """ + if not schema: + return {"type": "object", "properties": {}} + + result = {} + + # Handle type + if hasattr(schema, "type") and schema.type: + if hasattr(schema.type, "value"): + result["type"] = schema.type.value.lower() + else: + result["type"] = str(schema.type).lower() + else: + result["type"] = "object" + + # Handle properties + if hasattr(schema, "properties") and schema.properties: + result["properties"] = {} + for prop_name, prop_schema in schema.properties.items(): + result["properties"][prop_name] = self._convert_schema_to_json(prop_schema) + else: + if result.get("type") == "object": + result["properties"] = {} + + # Handle description + if hasattr(schema, "description") and schema.description: + result["description"] = schema.description + + # Handle required fields + if hasattr(schema, "required") and schema.required: + result["required"] = schema.required + + # Handle items for arrays + if hasattr(schema, "items") and schema.items: + result["items"] = self._convert_schema_to_json(schema.items) + + # Handle additional properties + if hasattr(schema, "additional_properties") and schema.additional_properties is not None: + result["additionalProperties"] = schema.additional_properties + + return result + + @override + def parse_function(self, content: str) -> List[FunctionCall]: + """Parse function calls from complete JSON content. + + Args: + content: Complete content string containing JSON function calls + + Returns: + List of FunctionCall objects parsed from content + + Raises: + ValueError: If content cannot be parsed as JSON function calls + """ + function_calls = [] + + # Try to find JSON objects in the content + # Look for patterns like {"name": "...", "arguments": {...}} + json_pattern = r'\{[^{}]*"name"\s*:\s*"[^"]+"\s*,\s*"arguments"\s*:\s*\{[^{}]*\}[^{}]*\}' + + # Also look for nested JSON objects + brace_count = 0 + start_pos = -1 + + for i, char in enumerate(content): + if char == "{": + if brace_count == 0: + start_pos = i + brace_count += 1 + elif char == "}": + brace_count -= 1 + if brace_count == 0 and start_pos != -1: + # Found a complete JSON object + json_str = content[start_pos:i + 1] + try: + func_call = self._parse_json_function_call(json_str) + if func_call: + function_calls.append(func_call) + except Exception: # pylint: disable=broad-except + # Try regex pattern as fallback + pass + + # Fallback to regex pattern + if not function_calls: + matches = re.findall(json_pattern, content, re.DOTALL) + for match in matches: + try: + func_call = self._parse_json_function_call(match) + if func_call: + function_calls.append(func_call) + except Exception as ex: # pylint: disable=broad-except + raise ValueError(f"Failed to parse JSON function call: {ex}") + + return function_calls + + def _parse_json_function_call(self, json_str: str) -> Optional[FunctionCall]: + """Parse a single JSON function call. + + Args: + json_str: JSON string representing a function call + + Returns: + FunctionCall object or None if parsing fails + """ + try: + data = json_loads_repair(json_str.strip()) + + if not isinstance(data, dict): + return None + + name = data.get("name") + if not name: + return None + + arguments = data.get("arguments", {}) + if not isinstance(arguments, dict): + # Try to parse arguments as JSON string + if isinstance(arguments, str): + try: + arguments = json_loads_repair(arguments) + except json.JSONDecodeError: + arguments = {} + else: + arguments = {} + + return FunctionCall(name=name, args=arguments) + + except json.JSONDecodeError: + return None + except Exception: # pylint: disable=broad-except + return None diff --git a/trpc_agent_sdk/models/tool_prompt/_xml.py b/trpc_agent_sdk/models/tool_prompt/_xml.py new file mode 100644 index 000000000..06a66d72c --- /dev/null +++ b/trpc_agent_sdk/models/tool_prompt/_xml.py @@ -0,0 +1,224 @@ +# 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. +"""XML tool prompt class for TRPC Agent framework.""" + +import json +import re +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import Tool +from trpc_agent_sdk.utils import json_loads_repair + +from ._base import ToolPrompt + + +class XmlToolPrompt(ToolPrompt): + """XML tool prompt implementation based on Anthropic's format.""" + + def __init__(self): + """Initialize the XML tool prompt.""" + super().__init__() + + @override + def build_prompt(self, tools: List[Tool]) -> str: + """Build XML tool prompt from tools. + + Args: + tools: List of Tool objects to convert to XML prompt + + Returns: + XML-formatted tool prompt string + """ + if not tools: + return "" + + tool_descriptions = [] + + for tool in tools: + if tool.function_declarations: + for func_decl in tool.function_declarations: + tool_desc = self._format_tool_description( + func_decl.name or "", + func_decl.description or "", + func_decl.parameters, + ) + tool_descriptions.append(tool_desc) + + if not tool_descriptions: + return "" + + tool_use_prompt = ( + "In this environment you have access to a set of tools you can use to answer the user's question.\n" + "\n" + "Here are the tools available:\n" + "\n" + "\n".join(tool_descriptions) + "\n\n" + "\n" + "You MUST call them by using below format:\n" + "\n" + "\n" + "$TOOL_NAME\n" + "\n" + "<$PARAMETER_NAME>$PARAMETER_VALUE\n" + "...\n" + "\n" + "\n" + "\n" + "\n" + "For example, you can call search tool with below text:\n" + "\n" + "\n" + "search\n" + "\n" + "Where can i buy a house\n" + "\n" + "\n" + "\n") + + return tool_use_prompt + + def _format_tool_description(self, name: str, description: str, parameters) -> str: + """Format a single tool description in XML format. + + Args: + name: Tool name + description: Tool description + parameters: Tool parameters schema + + Returns: + XML-formatted tool description + """ + params_str = self._format_parameters(parameters) + + return ("\n" + f"{name}\n" + "\n" + f"{description}\n" + "\n" + "\n" + f"{params_str}\n" + "\n" + "") + + def _format_parameters(self, parameters) -> str: + """Format parameters schema for XML display. + + Args: + parameters: Parameters schema object + + Returns: + Formatted parameters string + """ + if not parameters: + return "" + + try: + # Convert parameters to a readable format + if hasattr(parameters, "properties") and parameters.properties: + param_lines = [] + for param_name, param_schema in parameters.properties.items(): + param_type = getattr(param_schema, "type", "string") + if hasattr(param_type, "value"): + param_type = param_type.value + param_desc = getattr(param_schema, "description", "") + + param_line = f"<{param_name}> ({param_type})" + if param_desc: + param_line += f": {param_desc}" + param_line += f" " + param_lines.append(param_line) + + return "\n".join(param_lines) + else: + # Fallback to JSON representation + return str(parameters) + except Exception: # pylint: disable=broad-except + return str(parameters) + + @override + def parse_function(self, content: str) -> List[FunctionCall]: + """Parse function calls from complete XML content. + + Args: + content: Complete content string containing XML function calls + + Returns: + List of FunctionCall objects parsed from content + + Raises: + ValueError: If content cannot be parsed as XML function calls + """ + function_calls = [] + + # Find all function_calls blocks + function_calls_pattern = r"(.*?)" + matches = re.findall(function_calls_pattern, content, re.DOTALL) + + for match in matches: + # Parse each invoke block within function_calls + invoke_pattern = r"(.*?)" + invoke_matches = re.findall(invoke_pattern, match, re.DOTALL) + + for invoke_content in invoke_matches: + try: + func_call = self._parse_single_invoke(invoke_content) + if func_call: + function_calls.append(func_call) + except Exception as ex: # pylint: disable=broad-except + raise ValueError(f"Failed to parse function call: {ex}") + + return function_calls + + def _parse_single_invoke(self, invoke_content: str) -> Optional[FunctionCall]: + """Parse a single invoke block. + + Args: + invoke_content: Content of an invoke block + + Returns: + FunctionCall object or None if parsing fails + """ + try: + # Extract tool name + tool_name_match = re.search(r"(.*?)", invoke_content, re.DOTALL) + if not tool_name_match: + return None + + tool_name = tool_name_match.group(1).strip() + + # Extract parameters + parameters_match = re.search(r"(.*?)", invoke_content, re.DOTALL) + parameters = {} + + if parameters_match: + params_content = parameters_match.group(1).strip() + # Parse individual parameter tags + param_pattern = r"<(\w+)>(.*?)" + param_matches = re.findall(param_pattern, params_content, re.DOTALL) + + for param_name, param_value in param_matches: + # Try to parse as JSON if it looks like JSON, otherwise keep as string + param_value = param_value.strip() + try: + if param_value.startswith(("{", "[", '"')) or param_value in ("true", "false", "null"): + parameters[param_name] = json_loads_repair(param_value) + else: + # Try to convert numbers + if param_value.isdigit(): + parameters[param_name] = int(param_value) + elif param_value.replace(".", "").isdigit(): + parameters[param_name] = float(param_value) + else: + parameters[param_name] = param_value + except json.JSONDecodeError: + parameters[param_name] = param_value + + return FunctionCall(name=tool_name, args=parameters) + + except Exception: # pylint: disable=broad-except + return None diff --git a/trpc_agent_sdk/planners/__init__.py b/trpc_agent_sdk/planners/__init__.py new file mode 100644 index 000000000..2a52a8424 --- /dev/null +++ b/trpc_agent_sdk/planners/__init__.py @@ -0,0 +1,30 @@ +# 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. +"""Planner package for TRPC Agent framework. + +This package provides planning capabilities for agents, allowing them to +structure their thinking and reasoning processes before taking actions. + +Classes: + BasePlanner: Abstract base class for all planners + BuiltInPlanner: Uses model's built-in thinking features + PlanReActPlanner: Enforces structured Plan-Reasoning-Action workflow +""" + +from trpc_agent_sdk.abc import PlannerABC as BasePlanner + +from ._built_in_planner import BuiltInPlanner +from ._plan_re_act_planner import PlanReActPlanner +from ._planning_processor import PlanningProcessor +from ._planning_processor import default_planning_processor + +__all__ = [ + "BasePlanner", + "BuiltInPlanner", + "PlanReActPlanner", + "PlanningProcessor", + "default_planning_processor", +] diff --git a/trpc_agent_sdk/planners/_built_in_planner.py b/trpc_agent_sdk/planners/_built_in_planner.py new file mode 100644 index 000000000..ca2a43e05 --- /dev/null +++ b/trpc_agent_sdk/planners/_built_in_planner.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 Apache-2.0. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Built-in Planner module for TRPC Agent framework. + +This module provides the BuiltInPlanner class which leverages the model's +native thinking capabilities rather than implementing custom planning logic. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.abc import PlannerABC as BasePlanner +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import ThinkingConfig + + +class BuiltInPlanner(BasePlanner): + """The built-in planner that uses model's built-in thinking features. + + This planner leverages the model's native reasoning capabilities by + applying thinking configuration to the LLM request. It delegates the + planning and reasoning process to the model itself. + + Attributes: + thinking_config: Config for model built-in thinking features. An error + will be returned if this field is set for models that don't support + thinking. + """ + + def __init__(self, *, thinking_config: ThinkingConfig): + """Initializes the built-in planner. + + Args: + thinking_config: Config for model built-in thinking features. An error + will be returned if this field is set for models that don't support + thinking. + """ + self.thinking_config = thinking_config + + def apply_thinking_config(self, llm_request: "LlmRequest") -> None: + """Applies the thinking config to the LLM request. + + Args: + llm_request: The LLM request to apply the thinking config to + """ + if self.thinking_config: + # Initialize config if not present + if not llm_request.config: + llm_request.config = GenerateContentConfig() + + # Apply thinking configuration + llm_request.config.thinking_config = self.thinking_config + + @override + def build_planning_instruction( + self, + context: InvocationContext, + llm_request: LlmRequest, + ) -> Optional[str]: + """Builds planning instruction for built-in thinking. + + For built-in planners, the thinking is handled by the model itself + through the thinking config, so no additional instructions are needed. + + Args: + context: The invocation context + llm_request: The LLM request + + Returns: + None since built-in thinking doesn't require custom instructions + """ + # Apply thinking config to enable model's built-in thinking + self.apply_thinking_config(llm_request) + + # No additional instruction needed since thinking is handled by the model + return None + + @override + def process_planning_response( + self, + context: InvocationContext, + response_parts: List[Part], + is_partial: bool = False, + ) -> Optional[List[Part]]: + """Processes the planning response for built-in thinking. + + For built-in planners, the model handles the thinking process internally, + so no post-processing of response parts is needed. + + Args: + context: The invocation context + response_parts: The LLM response parts + is_partial: Whether this is a partial response from streaming + + Returns: + None since no post-processing is needed for built-in thinking + """ + # Built-in thinking doesn't require response processing + # The model handles internal reasoning automatically + return None diff --git a/trpc_agent_sdk/planners/_plan_re_act_planner.py b/trpc_agent_sdk/planners/_plan_re_act_planner.py new file mode 100644 index 000000000..e9fe2fb01 --- /dev/null +++ b/trpc_agent_sdk/planners/_plan_re_act_planner.py @@ -0,0 +1,337 @@ +# 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. +# +# Directly reuse the types from adk-python +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Plan-ReAct Planner module for TRPC Agent framework. + +This module provides the PlanReActPlanner class which enforces a structured +Plan-Reasoning-Action workflow using XML-like tags to organize model responses. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.abc import PlannerABC as BasePlanner +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.types import Part + +# Planning workflow tags +PLANNING_TAG = "/*PLANNING*/" +REPLANNING_TAG = "/*REPLANNING*/" +REASONING_TAG = "/*REASONING*/" +ACTION_TAG = "/*ACTION*/" +FINAL_ANSWER_TAG = "/*FINAL_ANSWER*/" + + +class PlanReActPlanner(BasePlanner): + """Plan-ReAct planner that constrains the LLM response to generate a plan before any action/observation. + + This planner enforces a structured workflow: + 1. PLANNING: Generate an initial plan to solve the problem + 2. ACTION: Execute tools/actions based on the plan + 3. REASONING: Analyze results and determine next steps + 4. REPLANNING: Revise the plan if needed based on results + 5. FINAL_ANSWER: Provide the final response to the user + + Note: This planner does not require the model to support built-in thinking + features or setting the thinking config. + """ + + def __init__(self): + """Initialize the planner with streaming state tracking.""" + super().__init__() + # Streaming state tracking + self._accumulated_text = "" + self._current_section = "planning" # Start with planning section to make thought=True from beginning + self._is_in_final_answer = False + + @override + def build_planning_instruction( + self, + context: InvocationContext, + llm_request: LlmRequest, + ) -> str: + """Builds the structured planning instruction for Plan-ReAct workflow. + + Args: + context: The invocation context + llm_request: The LLM request + + Returns: + Comprehensive planning instruction for structured reasoning + """ + return self._build_nl_planner_instruction() + + @override + def process_planning_response( + self, + context: InvocationContext, + response_parts: List[Part], + is_partial: bool = False, + ) -> Optional[List[Part]]: + """Processes the LLM response to handle planning workflow with streaming support. + + This method: + 1. Accumulates streaming content when is_partial=True + 2. Separates planning/reasoning content from tool calls + 3. Marks planning content as "thoughts" (internal reasoning) based on accumulated text + 4. Preserves tool calls for execution + 5. Extracts final answers for user display + 6. Clears accumulated text when encountering final answer in complete response + + Args: + context: The invocation context + response_parts: The LLM response parts to process + is_partial: Whether this is a partial response (streaming) + + Returns: + Processed response parts with proper categorization + """ + if not response_parts: + return None + + preserved_parts = [] + first_fc_part_index = -1 + + # Process each part to separate content types + for i in range(len(response_parts)): + # Stop at the first (group of) function calls + if response_parts[i].function_call: + # Ignore and filter out function calls with empty names + if not response_parts[i].function_call.name: + continue + preserved_parts.append(response_parts[i]) + first_fc_part_index = i + break + + # Handle text parts with streaming awareness + self._handle_non_function_call_parts(response_parts[i], preserved_parts, is_partial) + + # Include remaining function calls after the first one + if first_fc_part_index > 0: + j = first_fc_part_index + 1 + while j < len(response_parts): + if response_parts[j].function_call: + preserved_parts.append(response_parts[j]) + j += 1 + else: + break + + # Clear accumulated text when we encounter final answer in complete response + if not is_partial and self._is_in_final_answer: + self._reset_streaming_state() + + return preserved_parts + + def _split_by_last_pattern(self, text: str, separator: str) -> tuple[str, str]: + """Splits the text by the last occurrence of the separator. + + Args: + text: The text to split + separator: The separator to split on + + Returns: + A tuple containing the text before the last separator and the text after + the last separator + """ + index = text.rfind(separator) + if index == -1: + return text, "" + return text[:index + len(separator)], text[index + len(separator):] + + def _handle_non_function_call_parts(self, response_part: Part, preserved_parts: List[Part], + is_partial: bool) -> None: + """Handles text parts of the response with streaming awareness. + + Args: + response_part: The response part to handle + preserved_parts: The mutable list of parts to store the processed parts in + is_partial: Whether this is a partial response (streaming) + """ + if not response_part.text: + preserved_parts.append(response_part) + return + + # Accumulate text for streaming responses + if is_partial: + self._accumulated_text += response_part.text + + # Update current section based on accumulated text + self._update_current_section() + + # Mark as thought if we're in a thinking section + if self._should_mark_as_thought(): + self._mark_as_thought(response_part) + + preserved_parts.append(response_part) + else: + # For complete responses, use the original logic + self._handle_complete_text_part(response_part, preserved_parts) + + def _handle_complete_text_part(self, response_part: Part, preserved_parts: List[Part]) -> None: + """Handles complete (non-streaming) text parts. + + Args: + response_part: The response part to handle + preserved_parts: The mutable list of parts to store the processed parts in + """ + if response_part.text and FINAL_ANSWER_TAG in response_part.text: + # Split reasoning and final answer + reasoning_text, final_answer_text = self._split_by_last_pattern(response_part.text, FINAL_ANSWER_TAG) + + if reasoning_text: + reasoning_part = Part(text=reasoning_text) + self._mark_as_thought(reasoning_part) + preserved_parts.append(reasoning_part) + + if final_answer_text: + preserved_parts.append(Part(text=final_answer_text)) + else: + response_text = response_part.text or "" + # If the part is a text part with a planning/reasoning/action tag, + # label it as reasoning + if response_text and any( + response_text.startswith(tag) for tag in [PLANNING_TAG, REASONING_TAG, ACTION_TAG, REPLANNING_TAG]): + self._mark_as_thought(response_part) + preserved_parts.append(response_part) + + def _mark_as_thought(self, response_part: Part) -> None: + """Marks the response part as thought. + + Args: + response_part: The mutable response part to mark as thought + """ + if response_part.text: + response_part.thought = True + + def _update_current_section(self) -> None: + """Updates the current section based on accumulated text.""" + # Check for section tags in accumulated text + if FINAL_ANSWER_TAG in self._accumulated_text: + self._is_in_final_answer = True + self._current_section = None # Final answer is not a thought section + elif PLANNING_TAG in self._accumulated_text: + self._current_section = "planning" + self._is_in_final_answer = False + elif REPLANNING_TAG in self._accumulated_text: + self._current_section = "replanning" + self._is_in_final_answer = False + elif REASONING_TAG in self._accumulated_text: + self._current_section = "reasoning" + self._is_in_final_answer = False + elif ACTION_TAG in self._accumulated_text: + self._current_section = "action" + self._is_in_final_answer = False + + def _should_mark_as_thought(self) -> bool: + """Determines if the current content should be marked as thought. + + Returns: + True if the current section is a thinking section, False otherwise + """ + # Always mark as thought unless we're explicitly in final answer section + # This ensures thought=True from the very first chunk + return not self._is_in_final_answer + + def _reset_streaming_state(self) -> None: + """Resets the streaming state for the next response.""" + self._accumulated_text = "" + self._current_section = None + self._is_in_final_answer = False + + def _build_nl_planner_instruction(self) -> str: + """Builds the NL planner instruction for the Plan-ReAct planner. + + Returns: + NL planner system instruction with structured workflow guidance + """ + high_level_preamble = f""" +When answering the question, try to leverage the available tools to gather the information instead of your memorized +knowledge. + +Follow this process when answering the question: (1) first come up with a plan in natural language text format; +(2) Then use tools to execute the plan and provide reasoning between tool code snippets to make a summary of current +state and next step. Tool code snippets and reasoning should be interleaved with each other. (3) In the end, return one +final answer. + +Follow this format when answering the question: (1) The planning part should be under {PLANNING_TAG}. (2) The tool +code snippets should be under {ACTION_TAG}, and the reasoning parts should be under {REASONING_TAG}. (3) The final +answer part should be under {FINAL_ANSWER_TAG}. +""" + + planning_preamble = f""" +Below are the requirements for the planning: +The plan is made to answer the user query if following the plan. The plan is coherent and covers all aspects of +information from user query, and only involves the tools that are accessible by the agent. The plan contains the +decomposed steps as a numbered list where each step should use one or multiple available tools. By reading the plan, +you can intuitively know which tools to trigger or what actions to take. +If the initial plan cannot be successfully executed, you should learn from previous execution results and revise your +plan. The revised plan should be under {REPLANNING_TAG}. Then use tools to follow the new plan. +""" + + reasoning_preamble = """ +Below are the requirements for the reasoning: +The reasoning makes a summary of the current trajectory based on the user query and tool outputs. Based on the tool +outputs and plan, the reasoning also comes up with instructions to the next steps, making the trajectory closer to the +final answer. +""" + + final_answer_preamble = """ +Below are the requirements for the final answer: +The final answer should be precise and follow query formatting requirements. Some queries may not be answerable with +the available tools and information. In those cases, inform the user why you cannot process their query and ask for more +information. +""" + + # Tool code requirements + tool_code_without_python_libraries_preamble = """ +Below are the requirements for the tool code: + +**Custom Tools:** The available tools are described in the context and can be directly used. +- Code must be valid self-contained Python snippets with no imports and no references to tools or Python libraries that + are not in the context. +- You cannot use any parameters or fields that are not explicitly defined in the APIs in the context. +- The code snippets should be readable, efficient, and directly relevant to the user query and reasoning steps. +- When using the tools, you should use the library name together with the function name, e.g., vertex_search.search(). +- If Python libraries are not provided in the context, NEVER write your own code other than the function calls using the + provided tools. +""" + + user_input_preamble = """ +VERY IMPORTANT instruction that you MUST follow in addition to the above instructions: + +You should ask for clarification if you need more information to answer the question. +You should prefer using the information available in the context instead of repeated tool use. +""" + + return "\n\n".join([ + high_level_preamble, + planning_preamble, + reasoning_preamble, + final_answer_preamble, + tool_code_without_python_libraries_preamble, + user_input_preamble, + ]) diff --git a/trpc_agent_sdk/planners/_planning_processor.py b/trpc_agent_sdk/planners/_planning_processor.py new file mode 100644 index 000000000..ba064015f --- /dev/null +++ b/trpc_agent_sdk/planners/_planning_processor.py @@ -0,0 +1,176 @@ +# 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. +"""Planning Processor module for TRPC Agent framework. + +This module provides the PlanningProcessor class which integrates planners +into the LLM request/response flow, handling planning instructions and +response processing. +""" + +from __future__ import annotations + +from typing import List +from typing import Optional + +from trpc_agent_sdk.abc import AgentABC +from trpc_agent_sdk.abc import PlannerABC as BasePlanner +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.types import Part + + +class PlanningProcessor: + """Processor for planning-related request and response handling. + + This class integrates planners into the LLM workflow by: + 1. Adding planning instructions to requests + 2. Processing responses to filter planning content + 3. Managing thought removal from requests + """ + + def process_request(self, llm_request: LlmRequest, agent: AgentABC, context: InvocationContext) -> Optional[Event]: + """Process the LLM request to add planning capabilities. + + Args: + llm_request: The LLM request to process + agent: The LlmAgent using the planner + context: The invocation context + + Returns: + Error event if processing fails, None if successful + """ + try: + planner = self._get_planner(agent) + if not planner: + return None + + # Apply built-in thinking config if applicable + from ._built_in_planner import BuiltInPlanner + + if isinstance(planner, BuiltInPlanner): + planner.apply_thinking_config(llm_request) + + # Add planning instructions + planning_instruction = planner.build_planning_instruction(context, llm_request) + if planning_instruction: + llm_request.append_instructions([planning_instruction]) + logger.debug("Added planning instruction for agent: %s", agent.name) + + # Remove thought content from request + self._remove_thought_from_request(llm_request) + + return None # Success + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing planning request for agent %s: %s", agent.name, ex) + return self._create_error_event(context, "planning_request_error", + f"Failed to process planning request: {str(ex)}") + + def process_response(self, + response_parts: List[Part], + agent: AgentABC, + context: InvocationContext, + event: Optional[Event] = None) -> Optional[List[Part]]: + """Process the LLM response using the planner. + + Args: + response_parts: The LLM response parts to process + agent: The LlmAgent using the planner + context: The invocation context + event: The optional event containing partial flag for streaming support + + Returns: + Processed response parts, or None if no processing occurred + """ + try: + if not response_parts: + return None + + planner = self._get_planner(agent) + if not planner: + return None + + # Get partial flag from event if available + is_partial = event.partial if event else False + + # Process the response using the planner with streaming support + processed_parts = planner.process_planning_response(context, response_parts, is_partial) + + if processed_parts: + logger.debug("Processed %s response parts with planner for agent: %s", len(processed_parts), agent.name) + return processed_parts + + return None # No processing needed + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error processing planning response for agent %s: %s", agent.name, ex) + # Return original parts on error to avoid breaking the flow + return response_parts + + def _get_planner(self, agent: AgentABC) -> Optional["BasePlanner"]: + """Get the planner from the agent if available. + + Args: + agent: The LlmAgent to get planner from + + Returns: + The planner instance or None if not available + """ + if not hasattr(agent, "planner") or not agent.planner: + return None + + if isinstance(agent.planner, BasePlanner): + return agent.planner + + # Fallback to PlanReActPlanner if planner is not a BasePlanner + from ._plan_re_act_planner import PlanReActPlanner + + logger.warning("Agent %s planner is not a BasePlanner, using PlanReActPlanner", agent.name) + return PlanReActPlanner() + + def _remove_thought_from_request(self, llm_request: LlmRequest) -> None: + """Remove thought flag from the request contents. + + Some planners mark content as thoughts which should not be sent to the LLM + in subsequent requests. The thought content commonly should be removed + which cause the context not completely. So remove this flag to + force pass the thought part to LLM. + + Args: + llm_request: The LLM request to process + """ + if not llm_request.contents: + return + + for content in llm_request.contents: + if not content.parts: + continue + for part in content.parts: + part.thought = None + + def _create_error_event(self, ctx: InvocationContext, error_code: str, error_message: str) -> Event: + """Create an error event with the agent name from context. + + Args: + ctx: The invocation context containing agent information + error_code: The error code for the event + error_message: The error message for the event + + Returns: + Event: Error event with proper attribution + """ + return Event( + invocation_id=ctx.invocation_id, + author=ctx.agent.name, + error_code=error_code, + error_message=error_message, + ) + + +# Create a default instance for convenience +default_planning_processor = PlanningProcessor() diff --git a/trpc_agent_sdk/runners.py b/trpc_agent_sdk/runners.py new file mode 100644 index 000000000..ffc409145 --- /dev/null +++ b/trpc_agent_sdk/runners.py @@ -0,0 +1,842 @@ +# 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. +"""Core runner implementations for TRPC Agent framework. + +This module provides the main entry point for agent execution, orchestrating +the entire flow including service management, context creation, and agent lifecycle. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import threading +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import Optional + +from trpc_agent_sdk import cancel +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.artifacts import BaseArtifactService +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.context import new_invocation_context_id +from trpc_agent_sdk.events import AgentCancelledEvent +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.memory import BaseMemoryService +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import Session +from trpc_agent_sdk.telemetry import tracer +from trpc_agent_sdk.telemetry import trace_cancellation +from trpc_agent_sdk.telemetry import trace_runner +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + + +class _PostTurnWorkerThread: + """Dedicated thread + event loop for deferred post-turn processing.""" + + def __init__( + self, + *, + name: str, + run_job: Callable[[InvocationContext], Awaitable[None]], + maxsize: int, + ) -> None: + self._name = name + self._run_job = run_job + self._maxsize = max(1, int(maxsize)) + self._thread: threading.Thread | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._queue: asyncio.Queue[InvocationContext | None] | None = None + self._ready = threading.Event() + self._closed = False + self._state_lock = threading.Lock() + + def start(self) -> None: + if self._thread is not None and self._thread.is_alive(): + return + with self._state_lock: + self._closed = False + self._ready.clear() + self._thread = threading.Thread(target=self._run, name=self._name, daemon=True) + self._thread.start() + self._ready.wait() + + def submit(self, job: InvocationContext) -> bool: + self.start() + with self._state_lock: + if self._closed: + return False + loop = self._loop + queue = self._queue + if loop is None or queue is None or not loop.is_running(): + return False + loop.call_soon_threadsafe(self._put_nowait, queue, job) + return True + + def stop(self, *, drain_timeout: float = 10.0, join_timeout: float = 5.0) -> bool: + with self._state_lock: + self._closed = True + loop = self._loop + queue = self._queue + thread = self._thread + if loop is not None and queue is not None and loop.is_running(): + future = asyncio.run_coroutine_threadsafe( + self._stop_after_drain(queue, drain_timeout), + loop, + ) + try: + future.result(timeout=drain_timeout + 1.0) + except concurrent.futures.TimeoutError: + pass + if thread is not None: + thread.join(timeout=join_timeout) + return not thread.is_alive() + return True + + def _run(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + self._queue = asyncio.Queue(maxsize=self._maxsize) + self._ready.set() + try: + loop.run_until_complete(self._worker()) + finally: + try: + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + finally: + loop.close() + self._loop = None + self._queue = None + + async def _worker(self) -> None: + assert self._queue is not None + while True: + item = await self._queue.get() + try: + if item is None: + return + await self._run_job(invocation_context=item) + finally: + self._queue.task_done() + + @staticmethod + def _put_nowait( + queue: asyncio.Queue[InvocationContext | None], + job: InvocationContext, + ) -> None: + try: + queue.put_nowait(job) + except asyncio.QueueFull: + logger.warning("Post-turn thread queue full; dropping deferred job") + + @staticmethod + async def _stop_after_drain( + queue: asyncio.Queue[InvocationContext | None], + drain_timeout: float, + ) -> None: + try: + await asyncio.wait_for(queue.join(), timeout=drain_timeout) + except asyncio.TimeoutError: + pass + await queue.put(None) + + +class Runner: + """The Runner class is used to run agents. + + It manages the execution of an agent within a session, handling message + processing, event generation, and interaction with various services like + artifact storage, session management, and memory. + + Attributes: + app_name: The application name of the runner. + agent: The root agent to run. + artifact_service: The artifact service for the runner. + session_service: The session service for the runner. + memory_service: The memory service for the runner. + """ + + app_name: str + """The app name of the runner.""" + agent: BaseAgent + """The root agent to run.""" + artifact_service: Optional[BaseArtifactService] = None + """The artifact service for the runner.""" + session_service: BaseSessionService + """The session service for the runner.""" + memory_service: Optional[BaseMemoryService] = None + """The memory service for the runner.""" + + def __init__( + self, + *, + app_name: str, + agent: BaseAgent, + session_service: BaseSessionService, + artifact_service: Optional[BaseArtifactService] = None, + memory_service: Optional[BaseMemoryService] = None, + enable_post_turn_processing: bool = True, + defer_post_turn_processing: bool = False, + post_turn_queue_maxsize: int = 256, + close_session_service_on_close: bool = True, + close_memory_service_on_close: bool = True, + ): + """Initializes the Runner. + + Args: + app_name: The application name of the runner. + agent: The root agent to run. + artifact_service: The artifact service for the runner. + session_service: The session service for the runner. + memory_service: The memory service for the runner. + enable_post_turn_processing: If False, skip post-turn summarization + and memory persistence entirely for this runner. + defer_post_turn_processing: If True, session summarization + memory + persistence run in a dedicated thread/event loop so request + completion is not blocked by post-turn I/O/LLM latency. + post_turn_queue_maxsize: Max buffered post-turn jobs per runner. + close_session_service_on_close: Whether :meth:`close` should close + the session service. Set to False when the service is managed + outside the runner, e.g. an application-level Redis connection + pool shared by many short-lived runners. + close_memory_service_on_close: Whether :meth:`close` should close + the memory service. Set to False when the service is managed + outside the runner. + """ + self.app_name = app_name + self.agent = agent + self.artifact_service = artifact_service + self.session_service = session_service + self.memory_service = memory_service + self._enable_post_turn_processing = enable_post_turn_processing + self._defer_post_turn_processing = defer_post_turn_processing + self._post_turn_thread: _PostTurnWorkerThread | None = None + self._post_turn_queue_maxsize = max(1, int(post_turn_queue_maxsize)) + self._close_session_service_on_close = close_session_service_on_close + self._close_memory_service_on_close = close_memory_service_on_close + + async def _run_post_turn_processing( + self, + *, + invocation_context: InvocationContext, + ) -> None: + """Run post-turn summarization + memory persistence.""" + session = invocation_context.session + try: + await self.session_service.create_session_summary(session, ctx=invocation_context) + if self.memory_service and self.memory_service.enabled: + await self.memory_service.store_session(session, agent_context=invocation_context.agent_context) + except Exception as exc: # noqa: BLE001 + logger.error( + "Post-turn processing failed for session %s: %s", + getattr(session, "id", "?"), + exc, + ) + + def _ensure_post_turn_worker(self) -> None: + """Start post-turn worker lazily on first enqueue.""" + if not self._defer_post_turn_processing: + return + if self._post_turn_thread is None: + self._post_turn_thread = _PostTurnWorkerThread( + name=f"runner-post-turn:{self.app_name}", + run_job=self._run_post_turn_processing, + maxsize=self._post_turn_queue_maxsize, + ) + self._post_turn_thread.start() + + async def _schedule_post_turn_processing( + self, + *, + invocation_context: InvocationContext, + ) -> None: + """Schedule post-turn work; non-blocking when deferred mode is on.""" + if not self._enable_post_turn_processing: + return + if not self._defer_post_turn_processing: + await self._run_post_turn_processing(invocation_context=invocation_context, ) + return + + self._ensure_post_turn_worker() + if self._post_turn_thread is None: + return + if not self._post_turn_thread.submit(invocation_context): + logger.warning( + "Post-turn thread unavailable for app %s; dropping deferred job", + self.app_name, + ) + + async def _shutdown_post_turn_worker(self) -> None: + """Gracefully flush and stop deferred post-turn processing.""" + if self._post_turn_thread is not None: + stopped = await asyncio.to_thread( + self._post_turn_thread.stop, + drain_timeout=10.0, + join_timeout=5.0, + ) + if not stopped: + logger.warning("Post-turn thread shutdown timed out for app %s", self.app_name) + self._post_turn_thread = None + + async def cancel_run_async( + self, + user_id: str, + session_id: str, + timeout: float = 1.0, + ) -> bool: + """Cancel a running run for the specified session. + + This method requests cancellation of an ongoing agent execution. + The cancellation is cooperative - the agent will stop at the next + cancellation checkpoint. + + Args: + user_id: The user ID of the session to cancel. + session_id: The session ID to cancel. + timeout: Timeout in seconds to wait for cancellation to complete. + This method will wait until the run is cleaned up + (i.e., AgentCancelledEvent is processed) or the timeout is reached. + Default is 1.0 seconds. + + Returns: + True if an active run was found and marked for cancellation, + False if no active run found for this session. + + Example: + runner = Runner(app_name="my_app", agent=agent, ...) + + # Start agent in background task + async def run_agent(): + async for event in runner.run_async( + user_id="user1", + session_id="session1", + new_message=... + ): + process(event) + + task = asyncio.create_task(run_agent()) + + # Later, cancel the run using same user_id and session_id + # Wait up to 2 seconds for cancellation to complete + success = await runner.cancel_run_async( + user_id="user1", + session_id="session1", + timeout=2.0 + ) + print(f"Cancellation requested: {success}") + """ + # cancel_run returns an asyncio.Event that will be set when cleanup_run is called, + # or None if no active run found + cleanup_event = await cancel.cancel_run(self.app_name, user_id, session_id) + + if cleanup_event is None: + return False + + try: + await asyncio.wait_for(cleanup_event.wait(), timeout=timeout) + logger.info("Cancel completed for user_id %s, session %s", user_id, session_id) + except asyncio.TimeoutError: + logger.warning( + "Cancel wait timeout (%ss) reached for user_id %s, session %s. The execution may still be running.", + timeout, user_id, session_id) + + return True + + async def run_async( + self, + *, + user_id: str, + session_id: str, + new_message: Content | list[Content], + run_config: RunConfig = RunConfig(), + agent_context: Optional[AgentContext] = None, + ) -> AsyncGenerator[Event, None]: + """Main entry method to run the agent in this runner. + + Args: + user_id: The user ID of the session. + session_id: The session ID of the session. + new_message: A new message to append to the session. + run_config: The run config for the agent. + agent_context: The agent context for user interaction control. If not provided, + a default one will be created. + + Yields: + The events generated by the agent. + """ + # Manually propagate span context using attach/detach instead of + # start_as_current_span. This ensures child spans (agent_run, call_llm, + # execute_tool, etc.) can correctly resolve their parent. + # We use start_span + attach/detach rather than start_as_current_span + # because __aexit__ of the context manager is not guaranteed to run when + # an async generator is cancelled, but try/finally always executes + # even under CancelledError (PEP 492). + with tracer.start_as_current_span("invocation"): + # Create default agent context if not provided + if agent_context is None: + agent_context = new_agent_context() + + session = await self.session_service.get_session(app_name=self.app_name, + user_id=user_id, + session_id=session_id, + agent_context=agent_context) + if not session: + # Create new session if not found - use create_session instead of save_session + session = await self.session_service.create_session(app_name=self.app_name, + user_id=user_id, + session_id=session_id, + agent_context=agent_context) + logger.debug("Created new session: %s", session.id) + else: + logger.debug("Using existing session: %s with %s events", session.id, len(session.events)) + + # Capture state before runner execution + state_begin = dict(session.state) + + session.conversation_count += 1 + history_content: Content | None = None + if isinstance(new_message, list): + user_message = new_message[-1] + history_content = new_message[0] + else: + user_message = new_message + invocation_context = self._new_invocation_context( + session, + new_message=user_message, + run_config=run_config, + agent_context=agent_context, + ) + root_agent = self.agent + if run_config.save_history_enabled and history_content: + history_event = Event(content=history_content, + invocation_id=invocation_context.invocation_id, + id=Event.new_id(), + author=root_agent.name) + await self.session_service.append_event(session=session, event=history_event) + + if user_message: + await self._append_new_message_to_session( + session, + user_message, + invocation_context, + ) + + invocation_context.agent = self._find_agent_to_run(session, root_agent, run_config) + + # Register for cancellation tracking + session_key = await cancel.register_run( + app_name=self.app_name, + user_id=user_id, + session_id=session_id, + ) + + # Store session_key in invocation_context for use by agents + invocation_context.session_key = session_key + + # Track the last non-streaming event for tracing + last_non_streaming_event = None + + # Track accumulated partial text for cancellation handling + temp_text = "" + + try: + # Support multiple levels of agent transfers + while True: + current_agent = invocation_context.agent + logger.debug("Running agent: %s", current_agent.name) + + transfer_requested = False + async for event in current_agent.run_async(invocation_context): + # Track partial text accumulation + if event.partial: + if event.content and event.content.parts: + for part in event.content.parts: + if part.text: + temp_text += part.text + else: + # Clear temp_text on full event + temp_text = "" + + if not event.partial: + await self.session_service.append_event(session=session, event=event) + # Track the last non-streaming event with content for tracing + # This excludes state update events which have content=None + if event.content is not None: + last_non_streaming_event = event + + # Skip yielding events that are not visible + if not event.visible: + if event.actions.transfer_to_agent: + raise ValueError("Agent transfer requested but invisible is not allowed.") + continue + + if run_config.streaming: + yield event + else: + if not event.partial: + yield event + + # Handle agent transfer if requested + if event.actions.transfer_to_agent: + transfer_target = event.actions.transfer_to_agent + logger.debug("Processing agent transfer from %s to: %s", current_agent.name, + transfer_target) + + # Check if transferring to the same agent + if transfer_target == current_agent.name: + logger.warning( + "Transfer to same agent '%s' detected, add 'already on agent'" + "message to let agent continue", transfer_target) + already_in_event = Event( + invocation_id=invocation_context.invocation_id, + author=current_agent.name, + partial=False, + content=Content(parts=[ + Part(text=f"You are already at the {current_agent.name}. Continue execution.") + ]), + ) + await self.session_service.append_event(session=session, event=already_in_event) + transfer_requested = True + continue + + # Find the target agent + target_agent = root_agent.find_agent(transfer_target) + if not target_agent: + logger.error("Transfer target agent '%s' not found in agent tree", transfer_target) + error_event = Event( + invocation_id=invocation_context.invocation_id, + author=current_agent.name, + error_message=f"Transfer target agent '{transfer_target}' not found", + error_code="transfer_target_not_found", + ) + await self.session_service.append_event(session=session, event=error_event) + yield error_event + return + + # Update the invocation context to use the target agent + invocation_context.agent = target_agent + # Update branch to reflect the new agent in the hierarchy + if current_agent.name == root_agent.name: + # Transferring from root agent to sub-agent + invocation_context.branch = f"{current_agent.name}.{target_agent.name}" + elif invocation_context.branch: + # Transferring between agents - construct branch from root to target + branch_parts = [root_agent.name] + agent = target_agent + agent_path = [] + while agent != root_agent: + agent_path.insert(0, agent.name) + agent = agent.parent_agent + invocation_context.branch = ".".join(branch_parts + agent_path) + logger.debug("Successfully transferred to agent: %s, branch: %s", transfer_target, + invocation_context.branch) + + # Mark that transfer was requested and break from current agent's event loop + transfer_requested = True + + # If no transfer was requested, we're done + if not transfer_requested: + logger.debug("No transfer requested by %s, ending execution", current_agent.name) + break + + # Trigger summarization/memory persistence. Can be deferred to a + # background worker to avoid blocking request completion. + await self._schedule_post_turn_processing(invocation_context=invocation_context, ) + + # Compute state after runner execution + state_end = dict(session.state) + if (last_non_streaming_event and last_non_streaming_event.actions + and last_non_streaming_event.actions.state_delta): + state_end.update(last_non_streaming_event.actions.state_delta) + + # Call trace function with runner execution details + trace_runner( + app_name=self.app_name, + user_id=user_id, + session_id=session_id, + invocation_context=invocation_context, + new_message=user_message, + last_event=last_non_streaming_event, + state_begin=state_begin, + state_end=state_end, + ) + + except RunCancelledException as ex: + logger.info("Run for session %s was cancelled", session_id) + logger.debug("Cancellation details: %s", ex, exc_info=True) + + # Capture partial state at cancellation point + state_partial = dict(session.state) + + # Handle session cleanup for cancellation (two scenarios: streaming vs non-streaming) + await cancel.handle_cancellation_session_cleanup( + session=session, + session_service=self.session_service, + invocation_id=invocation_context.invocation_id, + agent_name=invocation_context.agent.name, + branch=invocation_context.branch, + temp_text=temp_text, + ) + await self.session_service.update_session(session=session) + + # Trace the cancellation event + trace_cancellation( + app_name=self.app_name, + user_id=user_id, + session_id=session_id, + invocation_context=invocation_context, + reason=str(ex), + new_message=user_message, + last_event=last_non_streaming_event, + partial_text=temp_text, + state_begin=state_begin, + state_partial=state_partial, + ) + + # Yield cancellation event to notify client + yield AgentCancelledEvent( + invocation_id=invocation_context.invocation_id, + author=invocation_context.agent.name, + reason=str(ex), + branch=invocation_context.branch, + ) + + finally: + # Always cleanup cancellation tracking + await cancel.cleanup_run( + app_name=self.app_name, + user_id=user_id, + session_id=session_id, + ) + + async def _append_new_message_to_session( + self, + session: Session, + new_message: Content, + invocation_context: InvocationContext, + ): + """Appends a new message to the session. + + Args: + session: The session to append the message to. + new_message: The new message to append. + invocation_context: The invocation context for the message. + """ + if not new_message.parts: + raise ValueError("No parts in the new_message.") + + # Create user event using the factory method + event = Event( + invocation_id=invocation_context.invocation_id, + author="user", + content=new_message, + ) + + # Add event to session using the session service + await self.session_service.append_event(session=session, event=event) + + def _find_agent_to_run( + self, + session: Session, + root_agent: BaseAgent, + run_config: RunConfig, + ) -> BaseAgent: + """Finds the agent to run to continue the session. + + A qualified agent must be either of: + - The root agent; + - The last agent who replied in previous turns. + - For human-in-the-loop scenarios: the agent who triggered the long-running operation. + - When start_from_last_agent is True: always try the last responding agent + from previous turns, regardless of transferability. + + Args: + session: The session to find the agent for. + root_agent: The root agent of the runner. + run_config: The run config containing start_from_last_agent setting. + + Returns: + The agent of the last message in the session or the root agent. + """ + # Check for human-in-the-loop scenario: last event is user input with function_response + if session.events: + last_event = session.events[-1] + + # Check if last event is a user event with FunctionResponse + if last_event.author == "user": + # Extract the function_response from user input (only one expected) + function_responses = last_event.get_function_responses() + if function_responses: + user_function_response = function_responses[0] + + # Search backwards for matching function_call + for event in reversed(session.events[:-1]): # Exclude the last event + if event.author != "user": + function_calls = event.get_function_calls() + for function_call in function_calls: + if function_call.id == user_function_response.id: + # Found matching function_call - resume from this agent + agent_name = event.author + logger.debug("Detected human-in-the-loop: function_call.id=%s, resuming agent: %s", + function_call.id, agent_name) + + if agent_name == root_agent.name: + return root_agent + + agent = root_agent.find_agent(agent_name) + if agent: + logger.debug("Resuming agent %s after human-in-the-loop", agent_name) + return agent + else: + logger.warning("Agent %s not found in agent tree, falling back to root agent", + agent_name) + return root_agent + + # When start_from_last_agent is enabled, try to find the last responding agent + if run_config.start_from_last_agent: + for event in reversed(session.events): + if event.author == "user": + continue + if event.author == root_agent.name: + # Found root agent. + return root_agent + if not (agent := root_agent.find_agent(event.author)): + # Agent not found, continue looking. + logger.warning( + "Event from an unknown agent: %s, event id: %s", + event.author, + event.invocation_id, + ) + continue + logger.debug("Starting from last agent: %s", agent.name) + return agent + # Falls back to root agent if no suitable agents are found in the session. + return root_agent + + def _is_transferable_across_agent_tree(self, agent_to_run: BaseAgent) -> bool: + """Whether the agent to run can transfer to any other agent in the agent tree. + + This typically means all agent_to_run's parent through root agent can + transfer to their parent_agent. + + Args: + agent_to_run: The agent to check for transferability. + + Returns: + True if the agent can transfer, False otherwise. + """ + # Import here to avoid circular imports + from trpc_agent_sdk.agents._llm_agent import LlmAgent + + agent = agent_to_run + while agent: + # Only LLM-based Agent can provide agent transfer capability. + if not isinstance(agent, LlmAgent): + return False + if agent.disallow_transfer_to_parent: + return False + # Get parent agent if available + agent = agent.parent_agent + return True + + def _new_invocation_context( + self, + session: Session, + *, + new_message: Optional[Content] = None, + run_config: RunConfig = RunConfig(), + agent_context: AgentContext, + ) -> InvocationContext: + """Creates a new invocation context. + + Args: + session: The session for the context. + new_message: The new message for the context. + run_config: The run config for the context. + agent_context: The agent context for user interaction control. + + Returns: + The new invocation context. + """ + invocation_id = new_invocation_context_id() + + return InvocationContext( + artifact_service=self.artifact_service, + session_service=self.session_service, + memory_service=self.memory_service, + invocation_id=invocation_id, + agent=self.agent, + agent_context=agent_context, + session=session, + user_content=new_message, + run_config=run_config, + branch=self.agent.name, # Initialize root agent's branch + ) + + def _collect_toolset(self, agent: BaseAgent) -> set[BaseToolSet]: + """Collect all toolset instances from the agent and its sub-agents recursively. + + Args: + agent: The root agent to start collecting toolsets from + + Returns: + A set containing all BaseToolSet instances found in the agent hierarchy + """ + from .tools import BaseToolSet + + toolsets = set() + tools = getattr(agent, "tools", []) + for tool_union in tools: + if isinstance(tool_union, BaseToolSet): + toolsets.add(tool_union) + for sub_agent in agent.get_subagents(): + toolsets.update(self._collect_toolset(sub_agent)) + return toolsets + + async def _cleanup_toolsets(self, toolsets_to_close: set[BaseToolSet]): + """Safely close all provided toolsets. + + Args: + toolsets_to_close: Set of BaseToolSet instances to close + """ + if not toolsets_to_close: + return + + for toolset in toolsets_to_close: + try: + logger.debug("Closing toolset: %s", type(toolset).__name__) + await toolset.close() + logger.debug("Successfully closed toolset: %s", type(toolset).__name__) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error closing toolset %s: %s", type(toolset).__name__, ex) + + async def close(self): + """Gracefully close the runner and cleanup all resources. + + This will: + 1. Collect all toolset instances from the agent hierarchy + 2. Close each toolset with proper error handling + 3. Ensure all resources are released before shutdown + """ + await self._shutdown_post_turn_worker() + await self._cleanup_toolsets(self._collect_toolset(self.agent)) + if self.session_service and self._close_session_service_on_close: + await self.session_service.close() + if self.memory_service and self._close_memory_service_on_close: + await self.memory_service.close() diff --git a/trpc_agent_sdk/server/__init__.py b/trpc_agent_sdk/server/__init__.py new file mode 100644 index 000000000..873d73da4 --- /dev/null +++ b/trpc_agent_sdk/server/__init__.py @@ -0,0 +1,10 @@ +# 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. +"""TRPC Agent Ecosystem package. + +This package contains ecosystem components for the TRPC Agent framework, +including session services, memory services, knowledge management, and A2A capabilities. +""" diff --git a/trpc_agent_sdk/server/a2a/README.md b/trpc_agent_sdk/server/a2a/README.md new file mode 100644 index 000000000..2018de59b --- /dev/null +++ b/trpc_agent_sdk/server/a2a/README.md @@ -0,0 +1,172 @@ +# trpc-agent A2A Framework 原理说明 + +本文说明 `trpc_agent_sdk.server.a2a` 如何把 `trpc-agent` 接入 A2A 协议,并给出可对照的运行示例。 + +## 1. 框架支持 A2A 的核心原理 + +这一层本质上是一个 **双向协议适配器**: + +- **服务端方向**:`TrpcA2aAgentService` / `TrpcA2aAgentExecutor` 把 A2A 请求转换为 `Runner.run_async(...)` 调用,再把 `Event` 转回 A2A 流式事件。 +- **客户端方向**:`TrpcRemoteA2aAgent` 把本地 `Event/Content` 转换为 A2A 消息,调用远端 A2A 服务并把响应还原为本地 `Event`。 +- **关键策略**: + - metadata 使用**无前缀键**(如 `user_id`、`session_id`、`app_name`)。 + - 流式输出采用 **artifact-first**(优先通过 `TaskArtifactUpdateEvent` 传输内容分片)。 + +## 2. 原理图 + +```mermaid +flowchart LR + U[User / Caller] + C[TrpcRemoteA2aAgent\n客户端适配层] + A2AC[A2AClient] + HTTP[HTTP + A2A Protocol] + A2AS[A2AStarletteApplication\n+ DefaultRequestHandler] + SVC[TrpcA2aAgentService] + EXE[TrpcA2aAgentExecutor] + RUN[Runner] + AGENT[BaseAgent / LlmAgent] + TOOLS[Tools / Model / Session] + + U --> C + C --> A2AC + A2AC --> HTTP + HTTP --> A2AS + A2AS --> SVC + SVC --> EXE + EXE --> RUN + RUN --> AGENT + AGENT --> TOOLS + + TOOLS --> AGENT + AGENT --> RUN + RUN --> EXE + EXE -->|convert_event_to_a2a_events| A2AS + A2AS --> HTTP + HTTP --> A2AC + A2AC --> C + C --> U +``` + +## 3. 核心链路伪代码讲解 + +### 3.1 服务启动与装配(Server Bootstrap) + +对应核心文件: + +- `trpc_agent_sdk/server/a2a/_agent_service.py` +- `trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py` + +```python +def bootstrap_a2a_service(base_agent): + # 1) 组装 trpc Runner 所需依赖 + session_service = InMemorySessionService() # 或外部注入 + memory_service = optional_memory_service + + # 2) 用 BaseAgent 构建 A2A AgentExecutor 适配器 + svc = TrpcA2aAgentService( + service_name="my_service", + agent=base_agent, + session_service=session_service, + memory_service=memory_service, + executor_config=TrpcA2aAgentExecutorConfig(), + ) + svc.initialize() # 构建 AgentCard,开启 streaming capability + + # 3) 交给 A2A SDK 的 HTTP App + app = A2AStarletteApplication( + agent_card=svc.agent_card, + http_handler=DefaultRequestHandler(agent_executor=svc), + ) + return app +``` + +### 3.2 请求执行路径(A2A -> Runner -> A2A) + +对应核心文件: + +- `trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py` +- `trpc_agent_sdk/server/a2a/converters/_request_converter.py` +- `trpc_agent_sdk/server/a2a/converters/_event_converter.py` + +```python +async def execute(context, event_queue): + ensure context.message exists + if first request: + enqueue submitted status + + # A2A RequestContext -> trpc run_args + run_args = convert_a2a_request_to_trpc_agent_run_args(context) + # run_args includes: user_id, session_id(context_id), new_message, run_config(metadata) + + session = get_or_create_session(run_args.user_id, run_args.session_id) + enqueue working status(metadata={app_name, user_id, session_id}) + + aggregator = TaskResultAggregator() + async for trpc_event in runner.run_async(**run_args): + # Optional callback: filter/augment event + trpc_event = maybe_apply_event_callback(trpc_event) + if trpc_event is None: + continue + + # trpc Event -> A2A events (artifact-first) + for a2a_event in convert_event_to_a2a_events(trpc_event, on_event=aggregator.process_event): + await event_queue.enqueue_event(a2a_event) + + # flush terminal status + if aggregator still working and has message: + enqueue final artifact chunk(last_chunk=True) + enqueue completed status + else: + enqueue final status(aggregated state) +``` + +### 3.3 远程客户端路径(Runner 使用远程 A2A Agent) + +对应核心文件: + +- `trpc_agent_sdk/server/a2a/_remote_a2a_agent.py` + +```python +async def remote_agent_run(invocation_ctx): + ensure initialized: + discover AgentCard (if needed) + create A2AClient + + outgoing_msg = convert local content/event to A2A Message + outgoing_msg.context_id = session_id + outgoing_msg.metadata = build_request_message_metadata(invocation_ctx) + + streaming_req = SendStreamingMessageRequest(message=outgoing_msg, metadata=run_config.metadata) + stream = a2a_client.send_message_streaming(streaming_req) + + async for response in stream_with_cancel_check(stream, invocation_ctx.cancel_event): + result = response.result + # TaskArtifactUpdateEvent / TaskStatusUpdateEvent / Task / Message + for event in _events_from_response(result): + yield convert_to_local_Event(event) + + if cancelled and task_id known: + call a2a_client.cancel_task(task_id) +``` + +## 4. 关键设计点 + +- **协议无前缀元数据**:统一使用 `user_id/session_id/app_name/...`,读取逻辑见 `_utils.py`。 +- **artifact-first 流式输出**:中间分片通过 `TaskArtifactUpdateEvent` 输出,结束时补齐最终状态。 +- **取消语义打通**:本地 cancel event 与远端 `cancel_task` 同步。 +- **可插拔扩展**:`TrpcA2aAgentExecutorConfig` 支持 `user_id_extractor`、`event_callback`。 + +## 5. 与 `examples/a2a` 的对应关系 + +示例目录(可直接运行): + +- [examples/a2a/README.md](../../../examples/a2a/README.md) +- [examples/a2a/run_server.py](../../../examples/a2a/run_server.py) +- [examples/a2a/test_a2a.py](../../../examples/a2a/test_a2a.py) +- [examples/a2a/agent/agent.py](../../../examples/a2a/agent/agent.py) + +运行映射: + +1. `run_server.py` 创建 `TrpcA2aAgentService` 并挂到 `A2AStarletteApplication`。 +2. `test_a2a.py` 创建 `TrpcRemoteA2aAgent`,通过 `Runner` 发起 3 轮对话。 +3. 第 2 轮触发 `get_weather_report` 工具调用,展示工具事件与文本分片的 A2A 流式传输。 diff --git a/trpc_agent_sdk/server/a2a/__init__.py b/trpc_agent_sdk/server/a2a/__init__.py new file mode 100644 index 000000000..d580189af --- /dev/null +++ b/trpc_agent_sdk/server/a2a/__init__.py @@ -0,0 +1,25 @@ +# 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. + +from ._agent_card_builder import AgentCardBuilder +from ._agent_service import TrpcA2aAgentService +from ._remote_a2a_agent import TrpcRemoteA2aAgent +from ._utils import get_metadata +from ._utils import metadata_is_true +from ._utils import set_metadata +from .executor import TrpcA2aAgentExecutor +from .executor import TrpcA2aAgentExecutorConfig + +__all__ = [ + "AgentCardBuilder", + "TrpcA2aAgentService", + "TrpcRemoteA2aAgent", + "get_metadata", + "metadata_is_true", + "set_metadata", + "TrpcA2aAgentExecutor", + "TrpcA2aAgentExecutorConfig", +] diff --git a/trpc_agent_sdk/server/a2a/_agent_card_builder.py b/trpc_agent_sdk/server/a2a/_agent_card_builder.py new file mode 100644 index 000000000..99eaa783a --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_agent_card_builder.py @@ -0,0 +1,485 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import re +from typing import Dict +from typing import List +from typing import Optional + +from a2a.types import AgentCapabilities +from a2a.types import AgentCard +from a2a.types import AgentExtension +from a2a.types import AgentProvider +from a2a.types import AgentSkill +from a2a.types import SecurityScheme + +from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.agents import ChainAgent as SequentialAgent +from trpc_agent_sdk.agents import CycleAgent as LoopAgent +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents import ParallelAgent +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.tools import convert_toolunion_to_tool_list + +from ._constants import EXTENSION_TRPC_A2A_VERSION +from ._constants import INTERACTION_SPEC_VERSION + + +class AgentCardBuilder: + """Builder class for creating agent cards from TrpcAgent agents. + + This class provides functionality to convert TrpcAgent agents into A2A agent cards, + including extracting skills, capabilities, and metadata from various agent + types. + """ + + def __init__( + self, + *, + agent: BaseAgent, + rpc_url: Optional[str] = None, + capabilities: Optional[AgentCapabilities] = None, + doc_url: Optional[str] = None, + provider: Optional[AgentProvider] = None, + agent_version: Optional[str] = None, + security_schemes: Optional[Dict[str, SecurityScheme]] = None, + ): + if not agent: + raise ValueError('Agent cannot be None or empty.') + + self._agent = agent + # keep it empty, trpc-a2a server will replace it with yaml config + self._rpc_url = rpc_url or '' + self._capabilities = capabilities or AgentCapabilities() + self._doc_url = doc_url + self._provider = provider + self._security_schemes = security_schemes + self._agent_version = agent_version or '0.0.1' + + async def build(self) -> AgentCard: + """Build and return the complete agent card.""" + try: + primary_skills = await _build_primary_skills(self._agent) + sub_agent_skills = await _build_sub_agent_skills(self._agent) + all_skills = primary_skills + sub_agent_skills + + # Add trpc-a2a-version to capabilities.extensions. + capabilities = _capabilities_with_trpc_extension(self._capabilities) + + return AgentCard( + name=self._agent.name, + description=self._agent.description or 'An A2A Agent', + doc_url=self._doc_url, + url=f"{self._rpc_url.rstrip('/')}", + version=self._agent_version, + capabilities=capabilities, + skills=all_skills, + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + supports_authenticated_extended_card=False, + provider=self._provider, + security_schemes=self._security_schemes, + ) + except Exception as ex: # pylint: disable=broad-except + raise RuntimeError(f'Failed to build agent card for {self._agent.name}: {ex}') from ex + + +def _capabilities_with_trpc_extension(capabilities: Optional[AgentCapabilities]) -> AgentCapabilities: + """Ensure capabilities includes the trpc-a2a-version extension.""" + base = capabilities or AgentCapabilities() + exts = list(base.extensions) if base.extensions else [] + if not any(getattr(e, "uri", None) == EXTENSION_TRPC_A2A_VERSION for e in exts): + exts.append(AgentExtension( + uri=EXTENSION_TRPC_A2A_VERSION, + params={"version": INTERACTION_SPEC_VERSION}, + )) + return base.model_copy(update={"extensions": exts}) + + +# Module-level helper functions +async def _build_primary_skills(agent: BaseAgent) -> List[AgentSkill]: + """Build skills for any agent type.""" + if isinstance(agent, LlmAgent): + return await _build_llm_agent_skills(agent) + else: + return await _build_non_llm_agent_skills(agent) + + +async def _build_llm_agent_skills(agent: LlmAgent) -> List[AgentSkill]: + """Build skills for LLM agent.""" + skills = [] + + # 1. Agent skill (main model skill) + agent_description = _build_llm_agent_description_with_instructions(agent) + + skills.append( + AgentSkill( + id=agent.name, + name='model', + description=agent_description, + examples=None, + input_modes=_get_input_modes(agent), + output_modes=_get_output_modes(agent), + tags=['llm'], + )) + + # 2. Tool skills + if agent.tools: + try: + tool_skills = await _build_tool_skills(agent) + skills.extend(tool_skills) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to build tool skills: %s", ex) + + # 3. Planner skill + if agent.planner: + skills.append(_build_planner_skill(agent)) + + # 4. Code executor skill + # TODO: add code executor skill + # if agent.code_executor: + # skills.append(_build_code_executor_skill(agent)) + + return skills + + +async def _build_sub_agent_skills(agent: BaseAgent) -> List[AgentSkill]: + """Build skills for all sub-agents.""" + sub_agent_skills = [] + for sub_agent in agent.sub_agents: + try: + sub_skills = await _build_primary_skills(sub_agent) + for skill in sub_skills: + # Create a new skill instance to avoid modifying original if shared + aggregated_skill = AgentSkill( + id=f'{sub_agent.name}_{skill.id}', + name=f'{sub_agent.name}: {skill.name}', + description=skill.description, + examples=skill.examples, + input_modes=skill.input_modes, + output_modes=skill.output_modes, + tags=[f'sub_agent:{sub_agent.name}'] + (skill.tags or []), + ) + sub_agent_skills.append(aggregated_skill) + except Exception as ex: # pylint: disable=broad-except + # Log warning but continue with other sub-agents + logger.warning("Failed to build skills for sub-agent %s: %s", sub_agent.name, ex) + continue + + return sub_agent_skills + + +async def _build_tool_skills(agent: LlmAgent) -> List[AgentSkill]: + """Build skills for agent tools.""" + tool_skills = [] + canonical_tools = await convert_toolunion_to_tool_list(agent.tools, None) + + for tool in canonical_tools: + tool_name = (tool.name if hasattr(tool, 'name') and tool.name else tool.__class__.__name__) + + tool_skills.append( + AgentSkill( + id=f'{agent.name}-{tool_name}', + name=tool_name, + description=getattr(tool, 'description', f'Tool: {tool_name}'), + examples=None, + input_modes=None, + output_modes=None, + tags=['llm', 'tools'], + )) + + # A2A Only extract tool information instead of using tool at initialize. + # So we should close the tool's resources as Agent may not running in the same EventLoop as initialize process. + for toolset in agent.tools: + if isinstance(toolset, BaseToolSet): + await toolset.close() + + return tool_skills + + +def _build_planner_skill(agent: LlmAgent) -> AgentSkill: + """Build planner skill for LLM agent.""" + return AgentSkill( + id=f'{agent.name}-planner', + name='planning', + description='Can think about the tasks to do and make plans', + examples=None, + input_modes=None, + output_modes=None, + tags=['llm', 'planning'], + ) + + +def _build_code_executor_skill(agent: LlmAgent) -> AgentSkill: + """Build code executor skill for LLM agent.""" + return AgentSkill( + id=f'{agent.name}-code-executor', + name='code-execution', + description='Can execute code', + examples=None, + input_modes=None, + output_modes=None, + tags=['llm', 'code_execution'], + ) + + +async def _build_non_llm_agent_skills(agent: BaseAgent) -> List[AgentSkill]: + """Build skills for non-LLM agents.""" + skills = [] + + # 1. Agent skill (main agent skill) + agent_description = _build_agent_description(agent) + + # Determine agent type and name + agent_type = _get_agent_type(agent) + agent_name = _get_agent_skill_name(agent) + + skills.append( + AgentSkill( + id=agent.name, + name=agent_name, + description=agent_description, + examples=None, + input_modes=_get_input_modes(agent), + output_modes=_get_output_modes(agent), + tags=[agent_type], + )) + + # 2. Sub-agent orchestration skill (for agents with sub-agents) + if agent.sub_agents: + orchestration_skill = _build_orchestration_skill(agent, agent_type) + if orchestration_skill: + skills.append(orchestration_skill) + + return skills + + +def _build_orchestration_skill(agent: BaseAgent, agent_type: str) -> Optional[AgentSkill]: + """Build orchestration skill for agents with sub-agents.""" + sub_agent_descriptions = [] + for sub_agent in agent.sub_agents: + description = sub_agent.description or 'No description' + sub_agent_descriptions.append(f'{sub_agent.name}: {description}') + + if not sub_agent_descriptions: + return None + + return AgentSkill( + id=f'{agent.name}-sub-agents', + name='sub-agents', + description='Orchestrates: ' + '; '.join(sub_agent_descriptions), + examples=None, + input_modes=None, + output_modes=None, + tags=[agent_type, 'orchestration'], + ) + + +def _get_agent_type(agent: BaseAgent) -> str: + """Get the agent type for tagging.""" + if isinstance(agent, LlmAgent): + return 'llm' + elif isinstance(agent, SequentialAgent): + return 'sequential_workflow' + elif isinstance(agent, ParallelAgent): + return 'parallel_workflow' + elif isinstance(agent, LoopAgent): + return 'loop_workflow' + else: + return 'custom_agent' + + +def _get_agent_skill_name(agent: BaseAgent) -> str: + """Get the skill name based on agent type.""" + if isinstance(agent, LlmAgent): + return 'model' + elif isinstance(agent, (SequentialAgent, ParallelAgent, LoopAgent)): + return 'workflow' + else: + return 'custom' + + +def _build_agent_description(agent: BaseAgent) -> str: + """Build agent description from agent.description and workflow-specific descriptions.""" + description_parts = [] + + # Add agent description + if agent.description: + description_parts.append(agent.description) + + # Add workflow-specific descriptions for non-LLM agents + if not isinstance(agent, LlmAgent): + workflow_description = _get_workflow_description(agent) + if workflow_description: + description_parts.append(workflow_description) + + return (' '.join(description_parts) if description_parts else _get_default_description(agent)) + + +def _build_llm_agent_description_with_instructions(agent: LlmAgent) -> str: + """Build agent description including instructions for LlmAgents.""" + description_parts = [] + + # Add agent description + if agent.description: + description_parts.append(agent.description) + + # Add instruction (with pronoun replacement) - only for LlmAgent + if agent.instruction: + instruction = _replace_pronouns(agent.instruction) + description_parts.append(instruction) + + # Add global instruction (with pronoun replacement) - only for LlmAgent + if agent.global_instruction: + global_instruction = _replace_pronouns(agent.global_instruction) + description_parts.append(global_instruction) + + return (' '.join(description_parts) if description_parts else _get_default_description(agent)) + + +def _replace_pronouns(text: str) -> str: + """Replace pronouns and conjugate common verbs for agent description. + (e.g., "You are" -> "I am", "your" -> "my"). + """ + pronoun_map = { + # Longer phrases with verb conjugations + 'you are': 'I am', + 'you were': 'I was', + "you're": 'I am', + "you've": 'I have', + # Standalone pronouns + 'yours': 'mine', + 'your': 'my', + 'you': 'I', + } + + # Sort keys by length (descending) to ensure longer phrases are matched first. + # This prevents "you" in "you are" from being replaced on its own. + sorted_keys = sorted(pronoun_map.keys(), key=len, reverse=True) + + pattern = r'\b(' + '|'.join(re.escape(key) for key in sorted_keys) + r')\b' + + return re.sub( + pattern, + lambda match: pronoun_map[match.group(1).lower()], + text, + flags=re.IGNORECASE, + ) + + +def _get_workflow_description(agent: BaseAgent) -> Optional[str]: + """Get workflow-specific description for non-LLM agents.""" + if not agent.sub_agents: + return None + + if isinstance(agent, SequentialAgent): + return _build_sequential_description(agent) + elif isinstance(agent, ParallelAgent): + return _build_parallel_description(agent) + elif isinstance(agent, LoopAgent): + return _build_loop_description(agent) + + return None + + +def _build_sequential_description(agent: SequentialAgent) -> str: + """Build description for sequential workflow agent.""" + descriptions = [] + for i, sub_agent in enumerate(agent.sub_agents, 1): + sub_description = (sub_agent.description or f'execute the {sub_agent.name} agent') + if i == 1: + descriptions.append(f'First, this agent will {sub_description}') + elif i == len(agent.sub_agents): + descriptions.append(f'Finally, this agent will {sub_description}') + else: + descriptions.append(f'Then, this agent will {sub_description}') + return ' '.join(descriptions) + '.' + + +def _build_parallel_description(agent: ParallelAgent) -> str: + """Build description for parallel workflow agent.""" + descriptions = [] + for i, sub_agent in enumerate(agent.sub_agents): + sub_description = (sub_agent.description or f'execute the {sub_agent.name} agent') + if i == 0: + descriptions.append(f'This agent will {sub_description}') + elif i == len(agent.sub_agents) - 1: + descriptions.append(f'and {sub_description}') + else: + descriptions.append(f', {sub_description}') + return ' '.join(descriptions) + ' simultaneously.' + + +def _build_loop_description(agent: LoopAgent) -> str: + """Build description for loop workflow agent.""" + max_iterations = agent.max_iterations or 'unlimited' + descriptions = [] + for i, sub_agent in enumerate(agent.sub_agents): + sub_description = (sub_agent.description or f'execute the {sub_agent.name} agent') + if i == 0: + descriptions.append(f'This agent will {sub_description}') + elif i == len(agent.sub_agents) - 1: + descriptions.append(f'and {sub_description}') + else: + descriptions.append(f', {sub_description}') + return (f"{' '.join(descriptions)} in a loop (max {max_iterations} iterations).") + + +def _get_default_description(agent: BaseAgent) -> str: + """Get default description based on agent type.""" + agent_type_descriptions = { + LlmAgent: 'An LLM-based agent', + SequentialAgent: 'A sequential workflow agent', + ParallelAgent: 'A parallel workflow agent', + LoopAgent: 'A loop workflow agent', + } + + for agent_type, description in agent_type_descriptions.items(): + if isinstance(agent, agent_type): + return description + + return 'A custom agent' + + +def _get_input_modes(agent: BaseAgent) -> Optional[List[str]]: + """Get input modes based on agent model.""" + if not isinstance(agent, LlmAgent): + return None + + # This could be enhanced to check model capabilities + # For now, return None to use default_input_modes + return None + + +def _get_output_modes(agent: BaseAgent) -> Optional[List[str]]: + """Get output modes from Agent.generate_content_config.response_modalities.""" + if not isinstance(agent, LlmAgent): + return None + + if (hasattr(agent, 'generate_content_config') and agent.generate_content_config + and hasattr(agent.generate_content_config, 'response_modalities')): + return agent.generate_content_config.response_modalities + + return None diff --git a/trpc_agent_sdk/server/a2a/_agent_service.py b/trpc_agent_sdk/server/a2a/_agent_service.py new file mode 100644 index 000000000..508efa54d --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_agent_service.py @@ -0,0 +1,150 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""A2A service that uses unprefixed metadata and artifact-first streaming. + +This service provides a bridge between trpc-agent and the A2A protocol, allowing +users to easily deploy trpc-agent as an A2A service. It extends ``AgentExecutor`` +from the A2A SDK so it can be used directly with ``A2AStarletteApplication`` or +any other A2A-compatible server. +""" + +from __future__ import annotations + +import asyncio +from typing import Optional +from typing_extensions import override + +from a2a.server.agent_execution import AgentExecutor +from a2a.server.agent_execution.context import RequestContext +from a2a.server.events.event_queue import EventQueue +from a2a.types import AgentCard + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.memory import BaseMemoryService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import InMemorySessionService + +from ._agent_card_builder import AgentCardBuilder +from .executor import TrpcA2aAgentExecutor +from .executor import TrpcA2aAgentExecutorConfig + + +class TrpcA2aAgentService(AgentExecutor): + """A2A service that integrates trpc-agent with the standard A2A SDK (adk-python). + + This service provides a bridge between trpc-agent and the A2A protocol using + unprefixed metadata keys and artifact-first streaming. It extends ``AgentExecutor`` + from the A2A SDK so it can be used directly with ``A2AStarletteApplication``. + + Attributes: + agent: The trpc-agent BaseAgent to use (required). + agent_card: The A2A AgentCard metadata. Auto-built if not provided. + executor_config: Configuration for the TrpcA2aAgentExecutor. + """ + + def __init__( + self, + *, + service_name: str, + agent: BaseAgent, + agent_card: Optional[AgentCard] = None, + session_service: Optional[BaseSessionService] = None, + memory_service: Optional[BaseMemoryService] = None, + executor_config: Optional[TrpcA2aAgentExecutorConfig] = None, + ): + super().__init__() + self._agent = agent + self._agent_card = agent_card + self._service_name = service_name + self._session_service = session_service + self._memory_service = memory_service + self._executor_config = executor_config + + def initialize(self) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + loop.run_until_complete(self._initialize()) + + @property + def agent_card(self) -> Optional[AgentCard]: + """Return the agent card metadata. + + Returns: + AgentCard: The agent's metadata describing its capabilities + """ + return self._agent_card + + async def _initialize(self) -> None: + """Initialize Resources""" + if self._session_service is None: + self._session_service = InMemorySessionService() + + if self._agent_card is None: + builder = AgentCardBuilder(agent=self._agent) + self._agent_card = await builder.build() + + self._agent_card.capabilities.streaming = True + logger.info("Initialized A2A Agent Service %s for %s", self._service_name, self._agent.name) + + def _create_executor(self) -> TrpcA2aAgentExecutor: + runner = Runner( + app_name=self._service_name, + agent=self._agent, + session_service=self._session_service, + memory_service=self._memory_service, + ) + config = self._executor_config or TrpcA2aAgentExecutorConfig() + return TrpcA2aAgentExecutor(runner=runner, config=config) + + async def get_agent_card(self) -> AgentCard: + """Return the agent card metadata. + + Returns: + AgentCard: The agent's metadata describing its capabilities + """ + return self._agent_card + + @override + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Execute the agent's logic for a given A2A request context. + + Args: + context: A2A RequestContext containing the message and task information + event_queue: Queue to publish events to + """ + executor = self._create_executor() + await executor.execute(context, event_queue) + + @override + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel an ongoing task. + + Args: + context: A2A RequestContext containing task information + event_queue: Queue to publish cancellation status to + """ + executor = self._create_executor() + await executor.cancel(context, event_queue) diff --git a/trpc_agent_sdk/server/a2a/_constants.py b/trpc_agent_sdk/server/a2a/_constants.py new file mode 100644 index 000000000..8f3c27939 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_constants.py @@ -0,0 +1,102 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Constants for standard converters.""" + +A2A_DATA_FIELD_CODE_EXECUTION_CODE = "code" +"""Constants for code execution code.""" +A2A_DATA_FIELD_CODE_EXECUTION_LANGUAGE = "language" +"""Constants for code execution language.""" +A2A_DATA_FIELD_CODE_EXECUTION_OUTCOME = "outcome" +"""Constants for code execution outcome.""" +A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT = "output" +"""Constants for code execution output.""" +A2A_DATA_FIELD_TOOL_CALL_ARGS = "args" +"""Constants for tool call args.""" +A2A_DATA_FIELD_TOOL_CALL_RESPONSE = "response" +"""Constants for tool call response.""" +A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT = 'code_execution_result' +"""Constants for code execution result type.""" +A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE = 'executable_code' +"""Constants for executable code type.""" +A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL = 'function_call' +"""Constants for function call type.""" +A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE = 'function_response' +"""Constants for function response type.""" +A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA = 'streaming_function_call_delta' +"""Constants for streaming function call delta type.""" +A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = 'is_long_running' +"""Constants for data part metadata is long running key.""" +A2A_DATA_PART_METADATA_TYPE_KEY = 'type' +"""Constants for data part metadata type key.""" +A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = 'is_long_running' +"""Constants for A2A data part metadata is long running.""" + +A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL = 'function_call' +"""Constants for A2A data part metadata type.""" + +A2A_DATA_PART_METADATA_TYPE_KEY = 'type' +"""Constants for A2A data part metadata type.""" + +ARTIFACT_ID_SEPARATOR = "-" +"""Constants for artifact id separator.""" + +DEFAULT_ERROR_MESSAGE = "An error occurred during processing" +"""Constants for default error message.""" + +# Streaming function call type constants +A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL = "streaming_function_call" +"""Constants for streaming function call type.""" + +A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA = "streaming_function_call_delta" +"""Constants for streaming function call delta type.""" + +A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT = 'code_execution_result' +"""Constants for code execution result type.""" + +A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE = 'executable_code' +"""Constants for executable code type.""" + +A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE = 'function_response' +"""Constants for function response type.""" + +ARTIFACT_ID_SEPARATOR = "-" +"""Constants for artifact id separator.""" +DEFAULT_ERROR_MESSAGE = "An error occurred during processing" +"""Constants for default error message.""" +INTERACTION_SPEC_VERSION = "0.1" +"""Constants for interaction spec version.""" +MESSAGE_METADATA_INTERACTION_SPEC_VERSION_KEY = "interaction_spec_version" +"""Constants for interaction spec version key.""" +MESSAGE_METADATA_OBJECT_TYPE_KEY = "object_type" +"""Constants for message metadata object type key.""" +MESSAGE_METADATA_TAG_KEY = "tag" +"""Constants for message metadata tag key.""" +MESSAGE_METADATA_RESPONSE_ID_KEY = "llm_response_id" +"""Constants for message metadata response id key.""" +EXTENSION_TRPC_A2A_VERSION = "trpc-a2a-version" +"""Constants for extension trpc a2a version key.""" + +REQUEST_EUC_FUNCTION_CALL_NAME = 'trpc_agent_request_credential' +"""Constants for request euc function call name.""" + +TRPC_AGENT_CONTEXT_ID_SEPARATOR = "/" +"""Constants for trpc agent context id separator.""" diff --git a/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py b/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py new file mode 100644 index 000000000..e76540c50 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py @@ -0,0 +1,436 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Remote A2A agent that uses unprefixed metadata and artifact-first streaming. + +This module provides the TrpcRemoteA2aAgent class which extends BaseAgent +to communicate with remote A2A agents via the standard A2A SDK client (a2a-sdk). +It supports agent card discovery and message exchange with remote A2A services +over HTTP, using unprefixed metadata keys and artifact-first streaming. +""" + +from __future__ import annotations + +import asyncio +import uuid +from typing import Any +from typing import AsyncGenerator +from typing import List +from typing import Optional + +import httpx + +from a2a.client import A2ACardResolver +from a2a.client import A2AClient +from a2a.client.middleware import ClientCallContext +from a2a.types import AgentCard +from a2a.types import CancelTaskRequest +from a2a.types import Message +from a2a.types import MessageSendParams +from a2a.types import Role +from a2a.types import SendStreamingMessageRequest +from a2a.types import SendStreamingMessageResponse +from a2a.types import Task +from a2a.types import TaskArtifactUpdateEvent +from a2a.types import TaskIdParams +from a2a.types import TaskState +from a2a.types import TaskStatusUpdateEvent + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.telemetry import CustomMetricsReporter +from trpc_agent_sdk.telemetry import CustomTraceReporter +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from ._utils import get_metadata +from .converters import build_request_message_metadata +from .converters import convert_a2a_message_to_event +from .converters import convert_a2a_task_to_event +from .converters import convert_content_to_a2a_message +from .converters import convert_event_to_a2a_message + + +class TrpcRemoteA2aAgent(BaseAgent): + """Agent that communicates with a remote A2A agent via the standard A2A SDK client. + + Supports agent-card discovery via HTTP, A2A message conversion, session state + management, and streaming with artifact-first event ordering and unprefixed + metadata keys. + + The agent requires: + - agent_base_url: HTTP base URL for agent card discovery (if agent_card not provided) + - name: Agent name (must be unique identifier) + - description: Agent description (auto-populated from card if empty) + - agent_card: Optional AgentCard object (if not provided, will be discovered) + - a2a_client: Optional A2AClient object (if not provided, will be created) + """ + + agent_base_url: Optional[str] = None + + def __init__( + self, + name: str, + description: str = "", + agent_card: Optional[AgentCard] = None, + a2a_client: Optional[A2AClient] = None, + agent_base_url: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(name=name, description=description, **kwargs) + if not name or not name.strip(): + raise ValueError("name cannot be empty") + if agent_card is None and a2a_client is None and (not agent_base_url or not agent_base_url.strip()): + raise ValueError("Either agent_card, a2a_client, or agent_base_url must be provided") + + self.agent_base_url = agent_base_url.strip() if agent_base_url else None + self._agent_card: Optional[AgentCard] = agent_card + self._a2a_client: Optional[A2AClient] = a2a_client + self._httpx_client: Optional[httpx.AsyncClient] = None + self._initialized = False + + async def initialize(self) -> bool: + """Initialize the client with agent card discovery (if needed). + + Returns: + bool: True if initialization successful, False otherwise + """ + if self._initialized: + return True + + logger.debug("Initializing Remote A2A agent...") + try: + if self._httpx_client is None: + self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) + + self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) + + self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) + # add close method to class( needed define in class definition) + + if self._agent_card is None: + if not self.agent_base_url: + raise ValueError("agent_base_url is required when agent_card is not provided") + + card_resolver = A2ACardResolver( + httpx_client=self._httpx_client, + base_url=self.agent_base_url, + ) + self._agent_card = await card_resolver.get_agent_card() + + logger.debug("Agent Name: %s", self._agent_card.name) + logger.debug("Description: %s", self._agent_card.description) + logger.debug("Agent Card URL: %s", self._agent_card.url) + logger.debug("Capabilities: %s", self._agent_card.capabilities.model_dump_json()) + + if self._a2a_client is None: + self._a2a_client = A2AClient( + httpx_client=self._httpx_client, + agent_card=self._agent_card, + url=self._agent_card.url or self.agent_base_url, + ) + + if not self.description and self._agent_card and self._agent_card.description: + self.description = self._agent_card.description + + self._initialized = True + logger.debug("Successfully initialized remote A2A agent: %s", self.name) + return True + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to initialize remote A2A agent %s: %s", self.name, ex) + return False + + async def _stream_with_cancel_check( + self, + ctx: InvocationContext, + streaming_generator: AsyncGenerator[SendStreamingMessageResponse, None], + ) -> AsyncGenerator[SendStreamingMessageResponse, None]: + """Wrap a streaming generator with concurrent cancel checking.""" + cancel_event = await ctx.get_cancel_event() + stream_iter = streaming_generator.__aiter__() + + while True: + next_response_task = asyncio.create_task(stream_iter.__anext__()) + try: + cancel_wait_task = asyncio.create_task(cancel_event.wait()) + done, pending = await asyncio.wait( + [next_response_task, cancel_wait_task], + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + try: + await task + except (asyncio.CancelledError, StopAsyncIteration): + pass + + if cancel_wait_task in done: + logger.info("Cancel event triggered during streaming wait") + raise RunCancelledException("Run cancelled while waiting for stream response") + + if next_response_task in done: + try: + yield next_response_task.result() + except StopAsyncIteration: + return + except asyncio.CancelledError: + next_response_task.cancel() + try: + await next_response_task + except (asyncio.CancelledError, StopAsyncIteration): + pass + raise + + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + await ctx.raise_if_cancelled() + + if not self._initialized: + yield Event( + author=self.name, + error_message="Remote A2A agent is not initialized", + invocation_id=ctx.invocation_id, + branch=ctx.branch, + ) + return + + a2a_message = self._build_outgoing_message(ctx) + if not a2a_message: + logger.warning("Failed to convert content to A2A message. Emitting empty event.") + yield Event(author=self.name, content=Content(), invocation_id=ctx.invocation_id, branch=ctx.branch) + return + + a2a_message.context_id = ctx.session.id + request_meta = build_request_message_metadata(ctx) + existing = getattr(a2a_message, "metadata", None) or {} + if isinstance(existing, dict): + request_meta.update(existing) + a2a_message.metadata = request_meta + + metadata = None + configuration = None + if ctx.run_config: + metadata = ctx.run_config.agent_run_config.get("metadata", None) + configuration = ctx.run_config.agent_run_config.get("configuration", None) + + streaming_request = SendStreamingMessageRequest( + id=str(uuid.uuid4()), + params=MessageSendParams(message=a2a_message, metadata=metadata, configuration=configuration), + ) + + logger.debug("Sending A2A streaming request: %s", streaming_request) + await ctx.raise_if_cancelled() + + trace_reporter = CustomTraceReporter( + agent_name=self.name, + model_prefix="a2a", + tool_description_prefix="Remote A2A tool", + ) + metrics_reporter = CustomMetricsReporter( + agent_name=self.name, + model_prefix="a2a", + ) + task_id = None + + out_headers: dict[str, str] = {} + try: + from opentelemetry.propagate import inject + inject(out_headers) + except Exception: # pylint: disable=broad-except + pass + if ctx.user_id: + out_headers["X-User-ID"] = ctx.user_id + http_kwargs = {"headers": out_headers} if out_headers else {} + call_context = ClientCallContext(state={"http_kwargs": http_kwargs}) + + try: + event_count = 0 + streaming_gen = self._a2a_client.send_message_streaming( + streaming_request, + context=call_context, + ) + + async for response in self._stream_with_cancel_check(ctx, streaming_gen): + await ctx.raise_if_cancelled() + event_count += 1 + result = response.root.result + + if task_id is None and hasattr(result, "task_id"): + task_id = result.task_id + logger.debug("Captured task_id for cancellation: %s", task_id) + + for event in self._events_from_response(result, event_count, ctx): + trace_reporter.trace_event(ctx, event) + metrics_reporter.report_event(ctx, event) + yield event + + logger.debug("Streaming completed with %s events", event_count) + + except RunCancelledException: + logger.info( + "Remote A2A agent '%s' execution cancelled, sending cancel request to remote service", + self.name, + ) + if task_id: + try: + cancel_request = CancelTaskRequest( + id=str(uuid.uuid4()), + params=TaskIdParams(id=task_id), + ) + cancel_response = await self._a2a_client.cancel_task(cancel_request, context=call_context) + logger.info("Successfully sent cancel request for session_id: %s", ctx.session.id) + logger.debug("Cancel response: %s", cancel_response) + except Exception as cancel_error: # pylint: disable=broad-except + logger.warning("Failed to send cancel request to remote service: %s", cancel_error) + else: + logger.warning("No task_id captured, cannot send cancel request to remote service") + raise + + except Exception as ex: # pylint: disable=broad-except + error_message = f"A2A streaming request failed: {ex}" + logger.error(error_message, exc_info=True) + yield Event(author=self.name, + error_message=error_message, + invocation_id=ctx.invocation_id, + branch=ctx.branch) + + def _build_outgoing_message(self, ctx: InvocationContext) -> Optional[Message]: + """Build the outgoing A2A message from ctx.override_messages or session events.""" + if ctx.override_messages is not None: + logger.debug("Using override_messages for remote A2A agent: %s", self.name) + return convert_content_to_a2a_message(ctx.override_messages, role=Role.user) + + user_event = None + for event in reversed(ctx.session.events): + if event.author == "user" and event.content: + user_event = event + break + if not user_event or not user_event.content or not user_event.content.parts: + logger.warning("No content to send to remote A2A agent. Emitting empty event.") + return None + + return convert_event_to_a2a_message(user_event, ctx, role=Role.user) + + def _build_message_from_artifact_event(self, event: TaskArtifactUpdateEvent) -> Message: + artifact = event.artifact if hasattr(event, "artifact") else None + if not artifact: + return Message(role=Role.agent, parts=[]) + msg = Message( + role=Role.agent, + parts=artifact.parts or [], + message_id=getattr(artifact, "artifact_id", "") or "", + ) + msg.metadata = getattr(event, "metadata", None) + return msg + + def _ensure_non_streaming_for_discrete_events(self, event: Event) -> None: + if event.get_function_calls() or event.get_function_responses(): + event.partial = False + return + obj = getattr(event, "object", None) + if obj in ("tool.response", "postprocessing.code_execution"): + event.partial = False + return + if event.content and event.content.parts: + for part in event.content.parts: + if getattr(part, "code_execution_result", None) or getattr(part, "executable_code", None): + event.partial = False + return + + def _resolve_partial(self, metadata: Any) -> bool: + """Resolve the 'partial' flag from event metadata, defaulting to True.""" + if not metadata: + return True + partial = get_metadata(metadata, "partial") + if partial is None: + return True + if isinstance(partial, bool): + return partial + if isinstance(partial, str): + return partial.strip().lower() == "true" + return True + + def _events_from_response(self, result: Any, event_count: int, ctx: InvocationContext) -> List[Event]: + """Produce TrpcAgent events from one streaming response.""" + events: List[Event] = [] + + if isinstance(result, TaskArtifactUpdateEvent): + artifact = result.artifact if hasattr(result, "artifact") else None + last_chunk = result.last_chunk + if artifact is not None and (artifact.parts or not last_chunk): + if not artifact.parts and last_chunk: + logger.debug("[Event %s] Artifact last_chunk (empty), skip", event_count) + else: + msg = self._build_message_from_artifact_event(result) + partial = self._resolve_partial(result.metadata) + event = convert_a2a_message_to_event(msg, author=self.name, invocation_context=ctx, partial=partial) + self._ensure_non_streaming_for_discrete_events(event) + + if result.metadata: + streaming_tool_call = get_metadata(result.metadata, "streaming_tool_call") + if streaming_tool_call == "true" or streaming_tool_call is True: + if event.custom_metadata is None: + event.custom_metadata = {} + event.custom_metadata["streaming_tool_call"] = True + event.partial = True + + events.append(event) + logger.debug("[Event %s] Artifact (yielded), last_chunk=%s", event_count, last_chunk) + else: + logger.debug("[Event %s] Artifact (no parts / closing), skip", event_count) + + elif isinstance(result, TaskStatusUpdateEvent): + logger.debug("[Event %s] Status: %s", event_count, result.status.state) + if not result.status.message: + return events + msg = result.status.message + if msg.role == Role.user: + return events + + state = result.status.state + if state not in (TaskState.submitted, TaskState.working, TaskState.completed): + partial = self._resolve_partial(result.metadata) + ev = convert_a2a_message_to_event(msg, author=self.name, invocation_context=ctx, partial=partial) + events.append(ev) + return events + + elif isinstance(result, Task): + logger.debug("[Event %s] Task: %s", event_count, result.id) + events.append(convert_a2a_task_to_event(result, author=self.name, invocation_context=ctx)) + + elif isinstance(result, Message): + logger.debug("[Event %s] Message", event_count) + events.append(convert_a2a_message_to_event(result, author=self.name, invocation_context=ctx)) + + else: + logger.warning("[Event %s] Unknown response type: %s", event_count, type(result)) + events.append( + Event( + author=self.name, + content=Content(parts=[Part(text=f"Received unknown response type: {type(result).__name__}")]), + invocation_id=ctx.invocation_id, + branch=ctx.branch, + )) + + return events diff --git a/trpc_agent_sdk/server/a2a/_utils.py b/trpc_agent_sdk/server/a2a/_utils.py new file mode 100644 index 000000000..0353cd108 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_utils.py @@ -0,0 +1,53 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Metadata utilities using unprefixed keys.""" + +from __future__ import annotations + +from typing import Any +from typing import Optional + + +def set_metadata(metadata: dict[str, Any], key: str, value: Any) -> None: + """Set a metadata value for the given key.""" + metadata[key] = value + + +def get_metadata( + metadata: Optional[dict[str, Any]], + key: str, + default: Any = None, +) -> Any: + """Get a metadata value by key.""" + if not metadata: + return default + return metadata.get(key, default) + + +def metadata_is_true(metadata: Optional[dict[str, Any]], key: str) -> bool: + """Return whether a metadata key is set to a truthy boolean value.""" + value = get_metadata(metadata, key) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() == "true" + return False diff --git a/trpc_agent_sdk/server/a2a/converters/__init__.py b/trpc_agent_sdk/server/a2a/converters/__init__.py new file mode 100644 index 000000000..f0a810a7a --- /dev/null +++ b/trpc_agent_sdk/server/a2a/converters/__init__.py @@ -0,0 +1,65 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Converters for the A2A <-> TrpcAgent boundary. + +Unprefixed metadata keys and artifact-first streaming. +""" + +from ._event_converter import build_request_message_metadata +from ._event_converter import convert_a2a_message_to_event +from ._event_converter import convert_a2a_task_to_event +from ._event_converter import convert_content_to_a2a_message +from ._event_converter import convert_event_to_a2a_events +from ._event_converter import convert_event_to_a2a_message +from ._event_converter import create_cancellation_event +from ._event_converter import create_completed_status_event +from ._event_converter import create_exception_status_event +from ._event_converter import create_final_status_event +from ._event_converter import create_submitted_status_event +from ._event_converter import create_working_status_event +from ._part_converter import convert_a2a_part_to_genai_part +from ._part_converter import convert_genai_part_to_a2a_part +from ._request_converter import UserIdExtractor +from ._request_converter import convert_a2a_cancel_request_to_run_args +from ._request_converter import convert_a2a_request_to_trpc_agent_run_args +from ._request_converter import get_user_session_id + +__all__ = [ + "build_request_message_metadata", + "convert_a2a_message_to_event", + "convert_a2a_task_to_event", + "convert_content_to_a2a_message", + "convert_event_to_a2a_events", + "convert_event_to_a2a_message", + "create_cancellation_event", + "create_completed_status_event", + "create_exception_status_event", + "create_final_status_event", + "create_submitted_status_event", + "create_working_status_event", + "convert_a2a_part_to_genai_part", + "convert_genai_part_to_a2a_part", + "UserIdExtractor", + "convert_a2a_cancel_request_to_run_args", + "convert_a2a_request_to_trpc_agent_run_args", + "get_user_session_id", +] diff --git a/trpc_agent_sdk/server/a2a/converters/_event_converter.py b/trpc_agent_sdk/server/a2a/converters/_event_converter.py new file mode 100644 index 000000000..04429329e --- /dev/null +++ b/trpc_agent_sdk/server/a2a/converters/_event_converter.py @@ -0,0 +1,733 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Event conversion between TrpcAgent events and A2A events. + +Metadata uses unprefixed keys. Streaming uses the artifact-first flow +(``TaskArtifactUpdateEvent``). +""" + +from __future__ import annotations + +_TYPE_TOOL_CALL = "tool_call" +_TYPE_TOOL_RESPONSE = "tool_response" +_TYPE_CODE_EXECUTION = "code_execution" +_TYPE_CODE_EXECUTION_RESULT = "code_execution_result" +_TYPE_STREAMING_TOOL_CALL = "streaming_tool_call" +_TYPE_TEXT = "text" + +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional +import uuid + +from a2a.server.events import Event as A2AEvent +from a2a.types import ( + Artifact, + DataPart, + Message, + Part as A2APart, + Role, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, + TextPart, +) +from google.genai import types as genai_types + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger + +from .._constants import INTERACTION_SPEC_VERSION +from .._constants import MESSAGE_METADATA_INTERACTION_SPEC_VERSION_KEY +from .._constants import MESSAGE_METADATA_OBJECT_TYPE_KEY +from .._constants import MESSAGE_METADATA_RESPONSE_ID_KEY +from .._constants import MESSAGE_METADATA_TAG_KEY +from .._constants import REQUEST_EUC_FUNCTION_CALL_NAME +from .._constants import A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY +from .._constants import A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL +from .._constants import A2A_DATA_PART_METADATA_TYPE_KEY +from .._constants import A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA +from .._constants import DEFAULT_ERROR_MESSAGE +from .._utils import get_metadata +from .._utils import metadata_is_true +from .._utils import set_metadata +from ._part_converter import convert_a2a_part_to_genai_part +from ._part_converter import convert_genai_part_to_a2a_part + + +def build_request_message_metadata(invocation_context: InvocationContext) -> Dict[str, Any]: + """Build ``Message.metadata`` for an outgoing A2A request.""" + metadata: Dict[str, Any] = { + MESSAGE_METADATA_INTERACTION_SPEC_VERSION_KEY: INTERACTION_SPEC_VERSION, + } + if invocation_context.invocation_id: + metadata["invocation_id"] = invocation_context.invocation_id + if invocation_context.user_id: + metadata["user_id"] = invocation_context.user_id + return metadata + + +def _serialize_metadata_value(value: Any) -> Any: + if hasattr(value, "model_dump"): + try: + return value.model_dump(exclude_none=True, by_alias=True) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to serialize metadata value: %s", ex) + return str(value) + return str(value) + + +def _infer_message_object_type(event: Event) -> Optional[str]: + if event.object: + return event.object + if not event.content or not event.content.parts: + return None + parts = event.content.parts + if any(p.function_response for p in parts): + return "tool.response" + if any(p.code_execution_result or p.executable_code for p in parts): + return "postprocessing.code_execution" + if any(p.function_call for p in parts): + return "chat.completion" + return "chat.completion.chunk" if event.partial else "chat.completion" + + +def _infer_a2a_message_object_type(parts: List[genai_types.Part], partial: bool = False) -> Optional[str]: + if any(p.function_response for p in parts): + return "tool.response" + if any(p.code_execution_result or p.executable_code for p in parts): + return "postprocessing.code_execution" + if any(p.function_call for p in parts): + return "chat.completion" + if any(p.text for p in parts): + return "chat.completion.chunk" if partial else "chat.completion" + return None + + +def _default_object_type(partial: bool = False) -> str: + return "chat.completion.chunk" if partial else "chat.completion" + + +def _infer_message_tag(event: Event) -> str: + if event.tag: + return event.tag + if not event.content or not event.content.parts: + return "" + if any(p.code_execution_result for p in event.content.parts): + return "code_execution_result" + if any(p.executable_code for p in event.content.parts): + return "code_execution_code" + return "" + + +def _get_event_type(event: Event) -> Optional[str]: + """Determine event type for conversion; streaming first, then object/tag, then content.""" + if not event.content or not event.content.parts: + return None + + if event.is_streaming_tool_call(): + return _TYPE_STREAMING_TOOL_CALL + + parts = event.content.parts + + if event.object: + if event.object == "tool.response": + return _TYPE_TOOL_RESPONSE + if event.object == "postprocessing.code_execution": + if any(p.code_execution_result for p in parts): + return _TYPE_CODE_EXECUTION_RESULT + return _TYPE_CODE_EXECUTION + if event.object in ("chat.completion", "chat.completion.chunk"): + if any(p.function_call for p in parts): + return _TYPE_TOOL_CALL + return _TYPE_TEXT + + if event.tag == "code_execution_result": + return _TYPE_CODE_EXECUTION_RESULT + if event.tag == "code_execution_code": + return _TYPE_CODE_EXECUTION + + if any(p.function_response for p in parts): + return _TYPE_TOOL_RESPONSE + if any(p.code_execution_result for p in parts): + return _TYPE_CODE_EXECUTION_RESULT + if any(p.executable_code for p in parts): + return _TYPE_CODE_EXECUTION + if any(p.function_call for p in parts): + return _TYPE_TOOL_CALL + if any(p.text for p in parts): + return _TYPE_TEXT + + return None + + +def _build_context_metadata(event: Event, ctx: InvocationContext) -> Dict[str, Any]: + """Build per-event metadata carrying context information.""" + metadata: Dict[str, Any] = { + "app_name": ctx.app_name, + "user_id": ctx.user_id, + "session_id": ctx.session.id, + "invocation_id": event.invocation_id, + "author": event.author, + } + partial = event.partial or False + optional_fields = [ + ("branch", event.branch), + ("grounding_metadata", event.grounding_metadata), + ("custom_metadata", event.custom_metadata), + ("usage_metadata", event.usage_metadata), + ("error_code", event.error_code), + ("partial", partial), + ] + for name, value in optional_fields: + if value is not None: + metadata[name] = _serialize_metadata_value(value) + return metadata + + +def _build_message_metadata(event: Event, effective_id: str) -> Dict[str, Any]: + """Build message/event metadata (object_type, tag, llm_response_id).""" + return { + MESSAGE_METADATA_OBJECT_TYPE_KEY: _infer_message_object_type(event) or "", + MESSAGE_METADATA_TAG_KEY: _infer_message_tag(event), + MESSAGE_METADATA_RESPONSE_ID_KEY: effective_id, + } + + +def _build_event_metadata(event: Event, message: Message, ctx: InvocationContext, effective_id: str) -> Dict[str, Any]: + metadata = _build_context_metadata(event, ctx) + msg_meta = _build_message_metadata(event, effective_id) + set_metadata(metadata, MESSAGE_METADATA_OBJECT_TYPE_KEY, msg_meta.get(MESSAGE_METADATA_OBJECT_TYPE_KEY) or "") + set_metadata(metadata, MESSAGE_METADATA_TAG_KEY, msg_meta.get(MESSAGE_METADATA_TAG_KEY) or "") + set_metadata(metadata, MESSAGE_METADATA_RESPONSE_ID_KEY, msg_meta.get(MESSAGE_METADATA_RESPONSE_ID_KEY) or "") + streaming_delta = A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA + if any( + get_metadata(p.root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) == streaming_delta for p in message.parts + if p.root.metadata): + set_metadata(metadata, "streaming_tool_call", "true") + return metadata + + +def _mark_long_running_tools(a2a_parts: List[A2APart], event: Event) -> None: + """Annotate parts that correspond to long-running tool calls.""" + if not event.long_running_tool_ids: + return + for a2a_part in a2a_parts: + root = a2a_part.root + if not isinstance(root, DataPart) or not root.metadata: + continue + if get_metadata(root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL: + continue + if root.data.get("id") not in event.long_running_tool_ids: + continue + set_metadata(root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, True) + + +def _effective_response_id(event: Event) -> str: + """Return ``response_id`` when present, otherwise a new UUID. + + Callers that need the same id across multiple locations should invoke this + once and pass the result explicitly. + """ + return event.response_id or str(uuid.uuid4()) + + +def _build_message(event: Event, a2a_parts: List[A2APart], role: Role, effective_id: str) -> Optional[Message]: + """Assemble an A2A Message from converted parts, or return None if empty.""" + if not a2a_parts: + return None + message = Message(message_id=effective_id, role=role, parts=a2a_parts) + msg_meta = _build_message_metadata(event, effective_id) + if msg_meta: + message.metadata = msg_meta + return message + + +def _is_streaming_delta(a2a_part: A2APart) -> bool: + meta = a2a_part.root.metadata + if meta is None: + return False + t = get_metadata(meta, A2A_DATA_PART_METADATA_TYPE_KEY) + return t == A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA + + +def _collect_parts( + event: Event, + *, + accept: Optional[callable] = None, + post: Optional[callable] = None, +) -> List[A2APart]: + """Convert genai parts to A2A parts with optional per-type filtering and post-processing. + + Args: + event: Source event whose content.parts will be converted. + accept: If provided, only A2A parts for which ``accept(part)`` is True are kept. + post: If provided, called on the collected list after filtering (e.g. to annotate). + """ + a2a_parts: List[A2APart] = [] + for gpart in event.content.parts: + a2a_part = convert_genai_part_to_a2a_part(gpart) + if a2a_part is None: + continue + if accept is not None and not accept(a2a_part): + continue + a2a_parts.append(a2a_part) + if post is not None: + post(a2a_parts, event) + return a2a_parts + + +_EVENT_TYPE_PART_RULES: dict[str, dict] = { + _TYPE_TOOL_CALL: { + "post": _mark_long_running_tools + }, + _TYPE_STREAMING_TOOL_CALL: { + "accept": _is_streaming_delta + }, + _TYPE_TOOL_RESPONSE: {}, + _TYPE_CODE_EXECUTION: {}, + _TYPE_CODE_EXECUTION_RESULT: {}, + _TYPE_TEXT: {}, +} + + +def convert_event_to_a2a_message( + event: Event, + invocation_context: InvocationContext, + role: Role = Role.agent, +) -> Optional[Message]: + """Convert a TrpcAgent Event to an A2A Message. + + Each event type defines its own part-selection and post-processing rules + via ``_EVENT_TYPE_PART_RULES``. Returns None when the event has no content. + """ + if not event: + raise ValueError("Event cannot be None") + if not invocation_context: + raise ValueError("Invocation context cannot be None") + if not event.content or not event.content.parts: + return None + + event_type = _get_event_type(event) + rules = _EVENT_TYPE_PART_RULES.get(event_type) if event_type else None + if rules is None: + return None + + a2a_parts = _collect_parts(event, **rules) + effective_id = _effective_response_id(event) + return _build_message(event, a2a_parts, role, effective_id) + + +def convert_content_to_a2a_message( + contents: List[genai_types.Content], + role: Role = Role.agent, +) -> Optional[Message]: + """Convert a list of Content objects to a single A2A Message. + + Raises: + ValueError: If *contents* is None or empty. + """ + if not contents: + raise ValueError("Contents cannot be None or empty") + + a2a_parts: List[A2APart] = [] + for content in contents: + if not content or not content.parts: + continue + for part in content.parts: + a2a_part = convert_genai_part_to_a2a_part(part) + if a2a_part: + a2a_parts.append(a2a_part) + + if a2a_parts: + return Message(message_id=str(uuid.uuid4()), role=role, parts=a2a_parts) + return None + + +def convert_a2a_task_to_event( + a2a_task: Task, + author: Optional[str] = None, + invocation_context: Optional[InvocationContext] = None, +) -> Event: + """Convert an A2A Task to a TrpcAgent Event. + + Raises: + ValueError: If *a2a_task* is None. + """ + if a2a_task is None: + raise ValueError("A2A task cannot be None") + + message = None + if a2a_task.artifacts: + message = Message( + message_id="", + role=Role.agent, + parts=a2a_task.artifacts[-1].parts, + metadata=getattr(a2a_task.artifacts[-1], "metadata", None), + ) + elif a2a_task.status and a2a_task.status.message: + message = a2a_task.status.message + elif a2a_task.history: + message = a2a_task.history[-1] + + if message: + return convert_a2a_message_to_event(message, author, invocation_context) + + return Event( + invocation_id=(invocation_context.invocation_id if invocation_context else str(uuid.uuid4())), + author=author or "a2a agent", + branch=invocation_context.branch if invocation_context else None, + ) + + +def convert_a2a_message_to_event( + a2a_message: Message, + author: Optional[str] = None, + invocation_context: Optional[InvocationContext] = None, + partial: bool = False, +) -> Event: + """Convert an A2A Message to a TrpcAgent Event. + + Raises: + ValueError: If *a2a_message* is None. + """ + if a2a_message is None: + raise ValueError("A2A message cannot be None") + + inv_id = invocation_context.invocation_id if invocation_context else str(uuid.uuid4()) + branch = invocation_context.branch if invocation_context else None + msg_meta = getattr(a2a_message, "metadata", None) + + if not a2a_message.parts: + logger.warning("A2A message has no parts, creating event with empty content") + return Event( + invocation_id=inv_id, + author=author or "a2a agent", + branch=branch, + content=genai_types.Content(role="model", parts=[]), + object=get_metadata(msg_meta, MESSAGE_METADATA_OBJECT_TYPE_KEY) or _default_object_type(partial), + tag=get_metadata(msg_meta, MESSAGE_METADATA_TAG_KEY), + response_id=get_metadata(msg_meta, MESSAGE_METADATA_RESPONSE_ID_KEY) or a2a_message.message_id, + partial=partial, + ) + + parts: List[genai_types.Part] = [] + long_running_tool_ids: set[str] = set() + + for a2a_part in a2a_message.parts: + try: + gpart = convert_a2a_part_to_genai_part(a2a_part) + if gpart is None: + logger.warning("Failed to convert A2A part, skipping: %s", a2a_part) + continue + is_lr = metadata_is_true(a2a_part.root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + if is_lr and gpart.function_call: + long_running_tool_ids.add(gpart.function_call.id) + parts.append(gpart) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to convert A2A part: %s, error: %s", a2a_part, ex) + continue + + if not parts: + logger.warning("No parts could be converted from A2A message %s", a2a_message) + + ot = get_metadata(msg_meta, MESSAGE_METADATA_OBJECT_TYPE_KEY) + object_type = ot or _infer_a2a_message_object_type(parts, partial=partial) or _default_object_type(partial) + + return Event( + invocation_id=inv_id, + author=author or "a2a agent", + branch=branch, + long_running_tool_ids=long_running_tool_ids or None, + object=object_type, + tag=get_metadata(msg_meta, MESSAGE_METADATA_TAG_KEY), + response_id=get_metadata(msg_meta, MESSAGE_METADATA_RESPONSE_ID_KEY) or a2a_message.message_id, + partial=partial, + content=genai_types.Content(role="model", parts=parts), + ) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def create_cancellation_event( + task_id: str, + context_id: str, + message_text: str, + final: bool = True, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus( + state=TaskState.canceled, + timestamp=_now_iso(), + message=Message( + message_id=str(uuid.uuid4()), + role=Role.agent, + parts=[TextPart(text=message_text)], + ), + ), + context_id=context_id, + final=final, + ) + + +def create_exception_status_event( + task_id: str, + context_id: str, + message_text: str, + final: bool = True, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus( + state=TaskState.failed, + timestamp=_now_iso(), + message=Message( + message_id=str(uuid.uuid4()), + role=Role.agent, + parts=[TextPart(text=message_text)], + ), + ), + context_id=context_id, + final=final, + ) + + +def create_submitted_status_event( + task_id: str, + context_id: str, + message: Message, + final: bool = False, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus(state=TaskState.submitted, message=message, timestamp=_now_iso()), + context_id=context_id, + final=final, + ) + + +def create_working_status_event( + task_id: str, + context_id: str, + metadata: Optional[Dict[str, Any]] = None, + final: bool = False, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus(state=TaskState.working, timestamp=_now_iso()), + context_id=context_id, + final=final, + metadata=metadata, + ) + + +def create_completed_status_event( + task_id: str, + context_id: str, + final: bool = True, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus(state=TaskState.completed, timestamp=_now_iso()), + context_id=context_id, + final=final, + ) + + +def create_final_status_event( + task_id: str, + context_id: str, + state: TaskState, + message: Optional[Message] = None, + final: bool = True, +) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + status=TaskStatus(state=state, timestamp=_now_iso(), message=message), + context_id=context_id, + final=final, + ) + + +def _create_error_status_event( + event: Event, + ctx: InvocationContext, + task_id: Optional[str], + context_id: Optional[str], +) -> TaskStatusUpdateEvent: + error_message = getattr(event, "error_message", None) or DEFAULT_ERROR_MESSAGE + event_metadata = _build_context_metadata(event, ctx) + if event.error_code: + set_metadata(event_metadata, "error_code", str(event.error_code)) + + error_msg_metadata: Dict[str, Any] = {} + if event.error_code: + set_metadata(error_msg_metadata, "error_code", str(event.error_code)) + + return TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + metadata=event_metadata, + status=TaskStatus( + state=TaskState.failed, + message=Message( + message_id=str(uuid.uuid4()), + role=Role.agent, + parts=[TextPart(text=error_message)], + metadata=error_msg_metadata, + ), + timestamp=_now_iso(), + ), + final=False, + ) + + +def _a2a_part_requests_euc_auth(part: A2APart) -> bool: + root = part.root + md = root.metadata + if not md: + return False + t = get_metadata(md, A2A_DATA_PART_METADATA_TYPE_KEY) + return (t == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + and metadata_is_true(md, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + and root.data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME) + + +def _a2a_part_is_long_running_function_call(part: A2APart) -> bool: + root = part.root + md = root.metadata + if not md: + return False + t = get_metadata(md, A2A_DATA_PART_METADATA_TYPE_KEY) + return (t == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + and metadata_is_true(md, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY)) + + +def _create_status_update_event( + message: Message, + ctx: InvocationContext, + event: Event, + task_id: Optional[str], + context_id: Optional[str], + effective_id: str = "", +) -> TaskStatusUpdateEvent: + status = TaskStatus(state=TaskState.working, message=message, timestamp=_now_iso()) + + if any(_a2a_part_requests_euc_auth(p) for p in message.parts): + status.state = TaskState.auth_required + elif any(_a2a_part_is_long_running_function_call(p) for p in message.parts): + status.state = TaskState.input_required + + return TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + status=status, + metadata=_build_event_metadata(event, message, ctx, effective_id), + final=False, + ) + + +def _create_artifact_update_event( + message: Message, + event: Event, + ctx: InvocationContext, + task_id: Optional[str] = None, + context_id: Optional[str] = None, + last_chunk: bool = False, + effective_id: str = "", +) -> TaskArtifactUpdateEvent: + artifact_id = "" if last_chunk else effective_id + return TaskArtifactUpdateEvent( + task_id=task_id, + context_id=context_id, + artifact=Artifact( + artifact_id=artifact_id, + parts=[] if last_chunk else message.parts, + ), + last_chunk=last_chunk, + metadata=_build_event_metadata(event, message, ctx, effective_id), + ) + + +def convert_event_to_a2a_events( + event: Event, + invocation_context: InvocationContext, + task_id: Optional[str] = None, + context_id: Optional[str] = None, + on_event: Optional[Callable[[A2AEvent], None]] = None, +) -> List[A2AEvent]: + """Convert a TrpcAgent Event to A2A events using the artifact-first flow. + + - Errors emit the error message (not wrapped in a status event). + - Non-final content emits a ``TaskArtifactUpdateEvent``. + - The ``on_event`` callback (if provided) receives the internal + ``TaskStatusUpdateEvent`` for state aggregation, regardless of what is + appended to the returned list. + """ + if not event: + raise ValueError("Event cannot be None") + if not invocation_context: + raise ValueError("Invocation context cannot be None") + + a2a_events: List[A2AEvent] = [] + + def _notify(evt: A2AEvent) -> None: + if on_event is not None: + on_event(evt) + + if event.error_code: + error_event = _create_error_status_event(event, invocation_context, task_id, context_id) + _notify(error_event) + if error_event.status and error_event.status.message: + a2a_events.append(error_event.status.message) + + message = convert_event_to_a2a_message(event, invocation_context) + if message: + effective_id = message.message_id + status_event = _create_status_update_event( + message, + invocation_context, + event, + task_id, + context_id, + effective_id=effective_id, + ) + _notify(status_event) + + if not event.is_final_response(): + artifact_event = _create_artifact_update_event( + message, + event, + invocation_context, + task_id=task_id, + context_id=context_id, + last_chunk=False, + effective_id=effective_id, + ) + a2a_events.append(artifact_event) + + return a2a_events diff --git a/trpc_agent_sdk/server/a2a/converters/_part_converter.py b/trpc_agent_sdk/server/a2a/converters/_part_converter.py new file mode 100644 index 000000000..aaa56cede --- /dev/null +++ b/trpc_agent_sdk/server/a2a/converters/_part_converter.py @@ -0,0 +1,321 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Conversion between A2A Part and Google GenAI Part.""" + +from __future__ import annotations + +import base64 +import json +from typing import Any +from typing import Optional + +from a2a import types as a2a_types +from google.genai import types as genai_types +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import TOOL_STREAMING_ARGS + +from .._constants import A2A_DATA_FIELD_CODE_EXECUTION_CODE +from .._constants import A2A_DATA_FIELD_CODE_EXECUTION_LANGUAGE +from .._constants import A2A_DATA_FIELD_CODE_EXECUTION_OUTCOME +from .._constants import A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT +from .._constants import A2A_DATA_FIELD_TOOL_CALL_ARGS +from .._constants import A2A_DATA_FIELD_TOOL_CALL_RESPONSE +from .._constants import A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT +from .._constants import A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE +from .._constants import A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL +from .._constants import A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE +from .._constants import A2A_DATA_PART_METADATA_TYPE_KEY +from .._constants import A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA +from .._utils import get_metadata +from .._utils import set_metadata + + +def _to_bool_metadata(value: Any) -> Optional[bool]: + """Convert metadata values to bool when possible.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + lower = value.strip().lower() + if lower == "true": + return True + if lower == "false": + return False + return None + + +def _stringify(value: Any) -> str: + """Convert code-execution field values to strings, unwrapping enums.""" + if value is None: + return "" + if hasattr(value, "value"): + value = value.value + return str(value) + + +def _a2a_string_field(value: Any) -> str: + """Serialize a DataPart field to A2A wire string.""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + + +def _typed_metadata(type_value: str) -> dict[str, Any]: + """Build metadata dict with a single ``type`` entry.""" + return {A2A_DATA_PART_METADATA_TYPE_KEY: type_value} + + +def _get_genai_part_kind(part: genai_types.Part) -> Optional[str]: + """Return the logical kind for a GenAI part.""" + if part.text: + return "text" + if part.file_data: + return "file_uri" + if part.inline_data: + return "inline_file" + if part.function_call: + args = part.function_call.args or {} + if args.get(TOOL_STREAMING_ARGS) is not None: + return "streaming_function_call" + return "function_call" + if part.function_response: + return "function_response" + if part.code_execution_result: + return "code_execution_result" + if part.executable_code: + return "executable_code" + return None + + +def _genai_text_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + a2a_part = a2a_types.TextPart(text=part.text) + if part.thought is not None: + a2a_part.metadata = {"thought": part.thought} + return a2a_types.Part(root=a2a_part) + + +def _genai_file_uri_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + return a2a_types.Part(root=a2a_types.FilePart(file=a2a_types.FileWithUri( + uri=part.file_data.file_uri, + mime_type=part.file_data.mime_type, + ))) + + +def _genai_inline_file_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + a2a_part = a2a_types.FilePart(file=a2a_types.FileWithBytes( + bytes=base64.b64encode(part.inline_data.data).decode("utf-8"), + mime_type=part.inline_data.mime_type, + )) + if part.video_metadata: + a2a_part.metadata = { + "video_metadata": part.video_metadata.model_dump(by_alias=True, exclude_none=True), + } + return a2a_types.Part(root=a2a_part) + + +def _genai_streaming_function_call_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + fc = part.function_call + tool_id = fc.id or f"tool_{fc.name}_{id(fc)}" + data: dict[str, Any] = { + "id": tool_id, + "name": fc.name, + "delta_args": (fc.args or {}).get(TOOL_STREAMING_ARGS), + } + metadata = _typed_metadata(A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA) + set_metadata(metadata, "streaming", True) + return a2a_types.Part(root=a2a_types.DataPart(data=data, metadata=metadata)) + + +def _function_call_data_for_a2a(raw: Any) -> dict[str, Any]: + """Build A2A DataPart data from function_call; args emitted as string, data.type=\"function\".""" + if not isinstance(raw, dict): + raw = raw.model_dump(by_alias=True, exclude_none=True) if hasattr(raw, "model_dump") else {} + out = dict(raw) + out.pop("type", None) + if A2A_DATA_FIELD_TOOL_CALL_ARGS in out: + out[A2A_DATA_FIELD_TOOL_CALL_ARGS] = _a2a_string_field(out[A2A_DATA_FIELD_TOOL_CALL_ARGS]) + out["type"] = "function" + return out + + +def _genai_function_call_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + data = _function_call_data_for_a2a(part.function_call) + return a2a_types.Part(root=a2a_types.DataPart( + data=data, + metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL), + )) + + +def _function_response_data_for_a2a(raw: Any) -> dict[str, Any]: + """Build A2A DataPart data from function_response; response emitted as string.""" + if not isinstance(raw, dict): + raw = raw.model_dump(by_alias=True, exclude_none=True) if hasattr(raw, "model_dump") else {} + out = dict(raw) + if A2A_DATA_FIELD_TOOL_CALL_RESPONSE in out: + out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE] = _a2a_string_field(out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE]) + return out + + +def _genai_function_response_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + data = _function_response_data_for_a2a(part.function_response) + return a2a_types.Part(root=a2a_types.DataPart( + data=data, + metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE), + )) + + +def _genai_code_execution_result_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + return a2a_types.Part(root=a2a_types.DataPart( + data={ + A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT: _stringify(part.code_execution_result.output), + A2A_DATA_FIELD_CODE_EXECUTION_OUTCOME: _stringify(part.code_execution_result.outcome), + }, + metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT), + )) + + +def _genai_executable_code_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: + return a2a_types.Part(root=a2a_types.DataPart( + data={ + A2A_DATA_FIELD_CODE_EXECUTION_CODE: _stringify(part.executable_code.code), + A2A_DATA_FIELD_CODE_EXECUTION_LANGUAGE: _stringify(part.executable_code.language) or "unknown", + }, + metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE), + )) + + +_GENAI_KIND_CONVERTERS: dict[str, callable] = { + "text": _genai_text_to_a2a, + "file_uri": _genai_file_uri_to_a2a, + "inline_file": _genai_inline_file_to_a2a, + "streaming_function_call": _genai_streaming_function_call_to_a2a, + "function_call": _genai_function_call_to_a2a, + "function_response": _genai_function_response_to_a2a, + "code_execution_result": _genai_code_execution_result_to_a2a, + "executable_code": _genai_executable_code_to_a2a, +} + + +def convert_genai_part_to_a2a_part(part: genai_types.Part) -> Optional[a2a_types.Part]: + """Convert a Google GenAI Part to an A2A Part.""" + kind = _get_genai_part_kind(part) + converter = _GENAI_KIND_CONVERTERS.get(kind) if kind else None + if converter: + return converter(part) + logger.warning("Cannot convert unsupported GenAI part kind: %s, part: %s", kind, part) + return None + + +def _normalize_function_call_data(data: Any) -> dict[str, Any]: + """Parse args JSON string to dict and drop extra 'type' for FunctionCall.""" + if not isinstance(data, dict): + return dict(data) if hasattr(data, "items") else {} + out = dict(data) + if A2A_DATA_FIELD_TOOL_CALL_ARGS in out and isinstance(out[A2A_DATA_FIELD_TOOL_CALL_ARGS], str): + try: + out[A2A_DATA_FIELD_TOOL_CALL_ARGS] = json.loads(out[A2A_DATA_FIELD_TOOL_CALL_ARGS]) + except (json.JSONDecodeError, TypeError): + out[A2A_DATA_FIELD_TOOL_CALL_ARGS] = {} + out.pop("type", None) + return out + + +def _normalize_function_response_data(data: Any) -> dict[str, Any]: + """Parse response for FunctionResponse; JSON string to dict, or wrap plain string in {\"content\": ...}.""" + if not isinstance(data, dict): + return dict(data) if hasattr(data, "items") else {} + out = dict(data) + if A2A_DATA_FIELD_TOOL_CALL_RESPONSE in out and isinstance(out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE], str): + raw = out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE] + try: + out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE] = json.loads(raw) + except (json.JSONDecodeError, TypeError): + out[A2A_DATA_FIELD_TOOL_CALL_RESPONSE] = {"content": raw} + return out + + +def _convert_streaming_function_call_delta(data: Any) -> genai_types.Part: + d = data or {} + return genai_types.Part(function_call=genai_types.FunctionCall( + id=d.get("id"), + name=d.get("name"), + args={TOOL_STREAMING_ARGS: d.get("delta_args", "")}, + ), ) + + +_A2A_DATA_TYPE_CONVERTERS: dict[str, callable] = { + A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL: + lambda d: genai_types.Part(function_call=genai_types.FunctionCall.model_validate(_normalize_function_call_data(d), + by_alias=True), ), + A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE: + lambda d: genai_types.Part(function_response=genai_types.FunctionResponse.model_validate( + _normalize_function_response_data(d), by_alias=True), ), + A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT: + lambda d: genai_types.Part(code_execution_result=genai_types.CodeExecutionResult.model_validate(d, by_alias=True), + ), + A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE: + lambda d: genai_types.Part(executable_code=genai_types.ExecutableCode.model_validate(d, by_alias=True), ), + A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA: + _convert_streaming_function_call_delta, +} + + +def _convert_a2a_data_part(part: a2a_types.DataPart) -> Optional[genai_types.Part]: + """Convert an A2A DataPart to a GenAI Part based on metadata type.""" + metadata_type = get_metadata(part.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) + converter = _A2A_DATA_TYPE_CONVERTERS.get(metadata_type) + if converter: + return converter(part.data) + return genai_types.Part(text=json.dumps(part.data)) + + +def convert_a2a_part_to_genai_part(a2a_part: a2a_types.Part) -> Optional[genai_types.Part]: + """Convert an A2A Part to a Google GenAI Part.""" + part = a2a_part.root + + if isinstance(part, a2a_types.TextPart): + thought = _to_bool_metadata(get_metadata(getattr(part, "metadata", None), "thought")) + kwargs: dict[str, Any] = {"text": part.text} + if thought is not None: + kwargs["thought"] = thought + return genai_types.Part(**kwargs) + + if isinstance(part, a2a_types.FilePart): + if isinstance(part.file, a2a_types.FileWithUri): + return genai_types.Part(file_data=genai_types.FileData(file_uri=part.file.uri, + mime_type=part.file.mime_type), ) + if isinstance(part.file, a2a_types.FileWithBytes): + return genai_types.Part(inline_data=genai_types.Blob( + data=base64.b64decode(part.file.bytes), + mime_type=part.file.mime_type, + )) + logger.warning("Cannot convert unsupported file type: %s for A2A part: %s", type(part.file), a2a_part) + return None + + if isinstance(part, a2a_types.DataPart): + return _convert_a2a_data_part(part) + + logger.warning("Cannot convert unsupported part type: %s for A2A part: %s", type(part), a2a_part) + return None diff --git a/trpc_agent_sdk/server/a2a/converters/_request_converter.py b/trpc_agent_sdk/server/a2a/converters/_request_converter.py new file mode 100644 index 000000000..453a80c09 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/converters/_request_converter.py @@ -0,0 +1,111 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Convert A2A request contexts to TrpcAgent run arguments.""" + +from __future__ import annotations + +import inspect +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing import Union + +from a2a.server.agent_execution import RequestContext +from google.genai import types as genai_types +from trpc_agent_sdk.configs import RunConfig + +from ._part_converter import convert_a2a_part_to_genai_part + +UserIdExtractor = Callable[[RequestContext], Union[str, Awaitable[str]]] + + +def _get_user_id_default(request: RequestContext) -> str: + """Default user-ID extraction: prefer call_context.user, fall back to context_id.""" + if request.call_context and request.call_context.user and request.call_context.user.user_name: + return request.call_context.user.user_name + return f"A2A_USER_{request.context_id}" + + +async def _resolve_user_id( + request: RequestContext, + user_id_extractor: Optional[UserIdExtractor] = None, +) -> str: + if user_id_extractor is None: + return _get_user_id_default(request) + result = user_id_extractor(request) + if inspect.iscoroutine(result): + return await result + return result + + +async def get_user_session_id( + request: RequestContext, + user_id_extractor: Optional[UserIdExtractor] = None, +) -> tuple[str, str]: + """Extract (user_id, session_id) from an A2A request context.""" + user_id = await _resolve_user_id(request, user_id_extractor) + return user_id, request.context_id + + +async def convert_a2a_request_to_trpc_agent_run_args( + request: RequestContext, + user_id_extractor: Optional[UserIdExtractor] = None, +) -> dict[str, Any]: + """Convert an A2A request to TrpcAgent ``run`` keyword arguments. + + Raises: + ValueError: If request.message is None. + """ + if not request.message: + raise ValueError("Request message cannot be None") + + user_id = await _resolve_user_id(request, user_id_extractor) + + raw_meta = getattr(request.message, "metadata", None) + request_metadata = dict(raw_meta) if isinstance(raw_meta, dict) else {} + + return { + "user_id": + user_id, + "session_id": + request.context_id, + "new_message": + genai_types.Content( + role="user", + parts=[convert_a2a_part_to_genai_part(part) for part in request.message.parts], + ), + "run_config": + RunConfig(agent_run_config={"metadata": request_metadata}), + } + + +async def convert_a2a_cancel_request_to_run_args( + request: RequestContext, + user_id_extractor: Optional[UserIdExtractor] = None, +) -> dict[str, str]: + """Extract (user_id, session_id) needed for cancellation.""" + user_id = await _resolve_user_id(request, user_id_extractor) + return { + "user_id": user_id, + "session_id": request.context_id, + } diff --git a/trpc_agent_sdk/server/a2a/executor/__init__.py b/trpc_agent_sdk/server/a2a/executor/__init__.py new file mode 100644 index 000000000..ad13d8852 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/executor/__init__.py @@ -0,0 +1,31 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._a2a_agent_executor import TrpcA2aAgentExecutor +from ._a2a_agent_executor import TrpcA2aAgentExecutorConfig +from ._task_result_aggregator import TaskResultAggregator + +__all__ = [ + "TrpcA2aAgentExecutor", + "TrpcA2aAgentExecutorConfig", + "TaskResultAggregator", +] diff --git a/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py b/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py new file mode 100644 index 000000000..17159bf24 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py @@ -0,0 +1,337 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""A2A agent executor that uses unprefixed metadata and artifact-first streaming.""" + +from __future__ import annotations + +import inspect +import uuid +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing import Union +from typing_extensions import override + +from a2a.server.agent_execution import AgentExecutor +from a2a.server.agent_execution.context import RequestContext +from a2a.server.events.event_queue import EventQueue +from a2a.types import Artifact +from a2a.types import TaskArtifactUpdateEvent +from a2a.types import TaskState +from pydantic import BaseModel +from trpc_agent_sdk.cancel import SessionKey +from trpc_agent_sdk.cancel import is_run_cancelled +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.events import AgentCancelledEvent +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.runners import Runner + +from .._utils import get_metadata +from ..converters import convert_a2a_request_to_trpc_agent_run_args +from ..converters import convert_event_to_a2a_events +from ..converters import create_cancellation_event +from ..converters import create_completed_status_event +from ..converters import create_exception_status_event +from ..converters import create_final_status_event +from ..converters import create_submitted_status_event +from ..converters import create_working_status_event +from ..converters import get_user_session_id +from ._task_result_aggregator import TaskResultAggregator + +UserIdExtractor = Callable[[RequestContext], Union[str, Awaitable[str]]] + +EventCallback = Callable[[Event, RequestContext], Union[Optional[Event], Awaitable[Optional[Event]]]] + + +class TrpcA2aAgentExecutorConfig(BaseModel): + """Configuration for TrpcA2aAgentExecutor. + + Attributes: + cancel_wait_timeout: Maximum seconds to wait for cancellation. Default 1.0. + user_id_extractor: Optional callback to extract user_id from RequestContext. + event_callback: Optional callback invoked for each TrpcAgent Event before + conversion to A2A events. Can be sync or async. Receives (Event, RequestContext). + Return the event to continue, a modified event to alter behavior, or None to + skip this event entirely. Useful for filtering, logging, or augmenting events + (e.g. detecting streaming tool calls via event.is_streaming_tool_call()). + """ + + model_config = {"arbitrary_types_allowed": True} + + cancel_wait_timeout: float = 1.0 + user_id_extractor: Optional[UserIdExtractor] = None + event_callback: Optional[EventCallback] = None + + +class TrpcA2aAgentExecutor(AgentExecutor): + """Executor that converts TrpcAgent events to A2A events using unprefixed + metadata and artifact-first streaming. + """ + + def __init__( + self, + *, + runner: Runner | Callable[..., Runner | Awaitable[Runner]], + config: Optional[TrpcA2aAgentExecutorConfig] = None, + ): + super().__init__() + self._runner = runner + self._config = config or TrpcA2aAgentExecutorConfig() + + @property + def _user_id_extractor(self) -> Optional[UserIdExtractor]: + return self._config.user_id_extractor if self._config else None + + async def _resolve_runner(self) -> Runner: + if isinstance(self._runner, Runner): + return self._runner + if callable(self._runner): + result = self._runner() + if inspect.iscoroutine(result): + resolved = await result + else: + resolved = result + self._runner = resolved + return resolved + raise TypeError(f"Runner must be a Runner instance or callable, got {type(self._runner)}") + + def _get_user_session_from_task_metadata( + self, + context: RequestContext, + ) -> tuple[Optional[str], Optional[str], Optional[str]]: + """Extract (app_name, user_id, session_id) from task metadata written by execute().""" + if not context.current_task or not context.current_task.metadata: + return None, None, None + metadata = context.current_task.metadata + return ( + get_metadata(metadata, "app_name"), + get_metadata(metadata, "user_id"), + get_metadata(metadata, "session_id"), + ) + + @override + async def cancel(self, context: RequestContext, event_queue: EventQueue): + runner = await self._resolve_runner() + + app_name_meta, user_id, session_id = self._get_user_session_from_task_metadata(context) + if user_id and session_id: + logger.info( + "Canceling task %s using metadata: app_name=%s, user_id=%s, session_id=%s", + context.task_id, + app_name_meta, + user_id, + session_id, + ) + else: + user_id, session_id = await get_user_session_id(context, self._user_id_extractor) + logger.info( + "Canceling task %s using fallback: user_id=%s, session_id=%s", + context.task_id, + user_id, + session_id, + ) + + timeout = self._config.cancel_wait_timeout if self._config else 1.0 + success = await runner.cancel_run_async(user_id, session_id, timeout=timeout) + + if success: + logger.info("Cancel requested for user_id=%s, session_id=%s", user_id, session_id) + else: + logger.warning( + "No active run found for task %s, user_id=%s, session_id=%s", + context.task_id, + user_id, + session_id, + ) + await event_queue.enqueue_event( + create_cancellation_event( + task_id=context.task_id, + context_id=context.context_id, + message_text="No active task found to cancel", + )) + + @override + async def execute(self, context: RequestContext, event_queue: EventQueue): + token = None + try: + call_context = getattr(context, "call_context", None) + state = getattr(call_context, "state", None) if call_context else None + headers = state.get("headers") if isinstance(state, dict) else {} + if isinstance(headers, dict) and headers: + from opentelemetry.propagate import extract + from opentelemetry.context import attach + token = attach(extract(headers)) + except Exception: # pylint: disable=broad-except + pass + + try: + if not context.message: + raise ValueError("A2A request must have a message") + + if not context.current_task: + await event_queue.enqueue_event( + create_submitted_status_event( + task_id=context.task_id, + context_id=context.context_id, + message=context.message, + )) + + try: + user_id, session_id = await get_user_session_id(context, self._user_id_extractor) + logger.info("Execute request for user_id: %s, session_id: %s", user_id, session_id) + + runner = await self._resolve_runner() + + session_key = SessionKey(runner.app_name, user_id, session_id) + if await is_run_cancelled(session_key): + logger.warning("Session %s is cancelled, rejecting new execution", session_id) + await event_queue.enqueue_event( + create_cancellation_event( + task_id=context.task_id, + context_id=context.context_id, + message_text="Session was cancelled", + )) + return + + await self._handle_request(context, event_queue) + except Exception as ex: # pylint: disable=broad-except + logger.error("Error handling A2A request: %s", ex, exc_info=True) + try: + except_event = create_exception_status_event( + task_id=context.task_id, + context_id=context.context_id, + message_text=str(ex), + ) + if except_event.status and except_event.status.message: + await event_queue.enqueue_event(except_event.status.message) + except Exception as enqueue_error: # pylint: disable=broad-except + logger.error("Failed to publish failure event: %s", enqueue_error, exc_info=True) + finally: + if token is not None: + try: + from opentelemetry.context import detach + detach(token) + except Exception: # pylint: disable=broad-except + pass + + async def _handle_request(self, context: RequestContext, event_queue: EventQueue): + runner = await self._resolve_runner() + run_args = await convert_a2a_request_to_trpc_agent_run_args(context, self._user_id_extractor) + + session = await self._prepare_session(run_args, runner) + agent_context = new_agent_context() + + invocation_context = runner._new_invocation_context( + session=session, + new_message=run_args["new_message"], + run_config=run_args["run_config"], + agent_context=agent_context, + ) + + working_meta: dict[str, Any] = { + "app_name": runner.app_name, + "user_id": run_args["user_id"], + "session_id": run_args["session_id"], + } + await event_queue.enqueue_event( + create_working_status_event( + task_id=context.task_id, + context_id=context.context_id, + metadata=working_meta, + )) + + aggregator = TaskResultAggregator() + event_callback = self._config.event_callback if self._config else None + async for trpc_event in runner.run_async(**run_args): + if isinstance(trpc_event, AgentCancelledEvent): + await event_queue.enqueue_event( + create_cancellation_event( + task_id=context.task_id, + context_id=context.context_id, + message_text="Task was cancelled", + )) + return + + if event_callback is not None: + result = event_callback(trpc_event, context) + if inspect.isawaitable(result): + result = await result + if result is None: + continue + trpc_event = result + + for a2a_event in convert_event_to_a2a_events( + trpc_event, + invocation_context, + context.task_id, + context.context_id, + on_event=aggregator.process_event, + ): + await event_queue.enqueue_event(a2a_event) + + if (aggregator.task_state == TaskState.working and aggregator.task_status_message is not None + and aggregator.task_status_message.parts): + final_meta: dict[str, Any] = {"partial": False} + await event_queue.enqueue_event( + TaskArtifactUpdateEvent( + task_id=context.task_id, + last_chunk=True, + context_id=context.context_id, + artifact=Artifact( + artifact_id=str(uuid.uuid4()), + parts=aggregator.task_status_message.parts, + ), + metadata=final_meta, + )) + await event_queue.enqueue_event( + create_completed_status_event( + task_id=context.task_id, + context_id=context.context_id, + )) + else: + await event_queue.enqueue_event( + create_final_status_event( + task_id=context.task_id, + context_id=context.context_id, + state=aggregator.task_state, + message=aggregator.task_status_message, + )) + + async def _prepare_session(self, run_args: dict[str, Any], runner: Runner): + session_id = run_args["session_id"] + user_id = run_args["user_id"] + session = await runner.session_service.get_session( + app_name=runner.app_name, + user_id=user_id, + session_id=session_id, + ) + if session is None: + session = await runner.session_service.create_session( + app_name=runner.app_name, + user_id=user_id, + state={}, + session_id=session_id, + ) + run_args["session_id"] = session.id + return session diff --git a/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py b/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py new file mode 100644 index 000000000..4a812173d --- /dev/null +++ b/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py @@ -0,0 +1,72 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from a2a.server.events import Event as A2AEvent +from a2a.types import Message +from a2a.types import TaskState +from a2a.types import TaskStatusUpdateEvent + + +class TaskResultAggregator: + """Aggregates the task status updates and provides the final task state.""" + + def __init__(self): + self._task_state = TaskState.working + self._task_status_message = None + + def process_event(self, event: A2AEvent): + """Process an event from the agent run and detect signals about the task status. + Priority of task state: + - failed + - auth_required + - input_required + - working + """ + if isinstance(event, TaskStatusUpdateEvent): + if event.status.state == TaskState.failed: + self._task_state = TaskState.failed + self._task_status_message = event.status.message + elif (event.status.state == TaskState.auth_required and self._task_state != TaskState.failed): + self._task_state = TaskState.auth_required + self._task_status_message = event.status.message + elif (event.status.state == TaskState.input_required + and self._task_state not in (TaskState.failed, TaskState.auth_required)): + self._task_state = TaskState.input_required + self._task_status_message = event.status.message + # final state is already recorded and make sure the intermediate state is + # always working because other state may terminate the event aggregation + # in a2a request handler + elif self._task_state == TaskState.working: + self._task_status_message = event.status.message + event.status.state = TaskState.working + + @property + def task_state(self) -> TaskState: + """Get the current task state.""" + return self._task_state + + @property + def task_status_message(self) -> Message | None: + """Get the current task status message.""" + return self._task_status_message diff --git a/trpc_agent_sdk/server/a2a/logs/__init__.py b/trpc_agent_sdk/server/a2a/logs/__init__.py new file mode 100644 index 000000000..71353a268 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/logs/__init__.py @@ -0,0 +1,31 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._log_utils import build_a2a_request_log +from ._log_utils import build_a2a_response_log +from ._log_utils import build_message_part_log + +__all__ = [ + "build_a2a_request_log", + "build_a2a_response_log", + "build_message_part_log", +] diff --git a/trpc_agent_sdk/server/a2a/logs/_log_utils.py b/trpc_agent_sdk/server/a2a/logs/_log_utils.py new file mode 100644 index 000000000..2a52bf7bc --- /dev/null +++ b/trpc_agent_sdk/server/a2a/logs/_log_utils.py @@ -0,0 +1,325 @@ +# 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. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utility functions for structured A2A request and response logging.""" + +from __future__ import annotations + +import json + +from a2a.types import DataPart as A2ADataPart +from a2a.types import Message as A2AMessage +from a2a.types import Part as A2APart +from a2a.types import SendMessageRequest +from a2a.types import SendMessageResponse +from a2a.types import Task as A2ATask +from a2a.types import TextPart as A2ATextPart + +# Constants +_NEW_LINE = "\n" +_EXCLUDED_PART_FIELD = {"file": {"bytes"}} + + +def _is_a2a_task(obj) -> bool: + """Check if an object is an A2A Task, with fallback for isinstance issues.""" + try: + return isinstance(obj, A2ATask) + except (TypeError, AttributeError): + return type(obj).__name__ == "Task" and hasattr(obj, "status") + + +def _is_a2a_message(obj) -> bool: + """Check if an object is an A2A Message, with fallback for isinstance issues.""" + try: + return isinstance(obj, A2AMessage) + except (TypeError, AttributeError): + return type(obj).__name__ == "Message" and hasattr(obj, "role") + + +def _is_a2a_text_part(obj) -> bool: + """Check if an object is an A2A TextPart, with fallback for isinstance issues.""" + try: + return isinstance(obj, A2ATextPart) + except (TypeError, AttributeError): + return type(obj).__name__ == "TextPart" and hasattr(obj, "text") + + +def _is_a2a_data_part(obj) -> bool: + """Check if an object is an A2A DataPart, with fallback for isinstance issues.""" + try: + return isinstance(obj, A2ADataPart) + except (TypeError, AttributeError): + return type(obj).__name__ == "DataPart" and hasattr(obj, "data") + + +def build_message_part_log(part: A2APart) -> str: + """Builds a log representation of an A2A message part. + + Args: + part: The A2A message part to log. + + Returns: + A string representation of the part. + """ + part_content = "" + if _is_a2a_text_part(part.root): + part_content = f"TextPart: {part.root.text[:100]}" + ("..." if len(part.root.text) > 100 else "") + elif _is_a2a_data_part(part.root): + # For data parts, show the data keys but exclude large values + data_summary = { + k: (f"<{type(v).__name__}>" if isinstance(v, (dict, list)) and len(str(v)) > 100 else v) + for k, v in part.root.data.items() + } + part_content = f"DataPart: {json.dumps(data_summary, indent=2)}" + else: + part_content = (f"{type(part.root).__name__}:" + f" {part.model_dump_json(exclude_none=True, exclude=_EXCLUDED_PART_FIELD)}") + + # Add part metadata if it exists + if hasattr(part.root, "metadata") and part.root.metadata: + metadata_str = json.dumps(part.root.metadata, indent=2).replace("\n", "\n ") + part_content += f"\n Part Metadata: {metadata_str}" + + return part_content + + +def build_a2a_request_log(req: SendMessageRequest) -> str: + """Builds a structured log representation of an A2A request. + + Args: + req: The A2A SendMessageRequest to log. + + Returns: + A formatted string representation of the request. + """ + # Message parts logs + message_parts_logs = [] + if req.params.message.parts: + for i, part in enumerate(req.params.message.parts): + part_log = build_message_part_log(part) + # Replace any internal newlines with indented newlines to maintain formatting + part_log_formatted = part_log.replace("\n", "\n ") + message_parts_logs.append(f"Part {i}: {part_log_formatted}") + + # Configuration logs + config_log = "None" + if req.params.configuration: + config_data = { + "accepted_output_modes": req.params.configuration.accepted_output_modes, + "blocking": req.params.configuration.blocking, + "history_length": req.params.configuration.history_length, + "push_notification_config": bool(req.params.configuration.push_notification_config), + } + config_log = json.dumps(config_data, indent=2) + + # Build message metadata section + message_metadata_section = "" + if req.params.message.metadata: + message_metadata_section = f""" + Metadata: + {json.dumps(req.params.message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}""" + + # Build optional sections + optional_sections = [] + + if req.params.metadata: + optional_sections.append(f"""----------------------------------------------------------- +Metadata: +{json.dumps(req.params.metadata, indent=2)}""") + + optional_sections_str = _NEW_LINE.join(optional_sections) + + return f""" +A2A Request: +----------------------------------------------------------- +Request ID: {req.id} +Method: {req.method} +JSON-RPC: {req.jsonrpc} +----------------------------------------------------------- +Message: + ID: {req.params.message.message_id} + Role: {req.params.message.role} + Task ID: {req.params.message.task_id} + Context ID: {req.params.message.context_id}{message_metadata_section} +----------------------------------------------------------- +Message Parts: +{_NEW_LINE.join(message_parts_logs) if message_parts_logs else "No parts"} +----------------------------------------------------------- +Configuration: +{config_log} +{optional_sections_str} +----------------------------------------------------------- +""" + + +def build_a2a_response_log(resp: SendMessageResponse) -> str: + """Builds a structured log representation of an A2A response. + + Args: + resp: The A2A SendMessageResponse to log. + + Returns: + A formatted string representation of the response. + """ + # Handle error responses + if hasattr(resp.root, "error"): + return f""" +A2A Response: +----------------------------------------------------------- +Type: ERROR +Error Code: {resp.root.error.code} +Error Message: {resp.root.error.message} +Error Data: {json.dumps(resp.root.error.data, indent=2) if resp.root.error.data else "None"} +----------------------------------------------------------- +Response ID: {resp.root.id} +JSON-RPC: {resp.root.jsonrpc} +----------------------------------------------------------- +""" + + # Handle success responses + result = resp.root.result + result_type = type(result).__name__ + + # Build result details based on type + result_details = [] + + if _is_a2a_task(result): + result_details.extend([ + f"Task ID: {result.id}", + f"Context ID: {result.context_id}", + f"Status State: {result.status.state}", + f"Status Timestamp: {result.status.timestamp}", + f"History Length: {len(result.history) if result.history else 0}", + f"Artifacts Count: {len(result.artifacts) if result.artifacts else 0}", + ]) + + # Add task metadata if it exists + if result.metadata: + result_details.append("Task Metadata:") + metadata_formatted = json.dumps(result.metadata, indent=2).replace("\n", "\n ") + result_details.append(f" {metadata_formatted}") + + elif _is_a2a_message(result): + result_details.extend([ + f"Message ID: {result.message_id}", + f"Role: {result.role}", + f"Task ID: {result.task_id}", + f"Context ID: {result.context_id}", + ]) + + # Add message parts + if result.parts: + result_details.append("Message Parts:") + for i, part in enumerate(result.parts): + part_log = build_message_part_log(part) + # Replace any internal newlines with indented newlines to maintain formatting + part_log_formatted = part_log.replace("\n", "\n ") + result_details.append(f" Part {i}: {part_log_formatted}") + + # Add metadata if it exists + if result.metadata: + result_details.append("Metadata:") + metadata_formatted = json.dumps(result.metadata, indent=2).replace("\n", "\n ") + result_details.append(f" {metadata_formatted}") + + else: + # Handle other result types by showing their JSON representation + if hasattr(result, "model_dump_json"): + try: + result_json = result.model_dump_json() + result_details.append(f"JSON Data: {result_json}") + except Exception: # pylint: disable=broad-except + result_details.append("JSON Data: ") + + # Build status message section + status_message_section = "None" + if _is_a2a_task(result) and result.status.message: + status_parts_logs = [] + if result.status.message.parts: + for i, part in enumerate(result.status.message.parts): + part_log = build_message_part_log(part) + # Replace any internal newlines with indented newlines to maintain formatting + part_log_formatted = part_log.replace("\n", "\n ") + status_parts_logs.append(f"Part {i}: {part_log_formatted}") + + # Build status message metadata section + status_metadata_section = "" + if result.status.message.metadata: + status_metadata_section = f""" +Metadata: +{json.dumps(result.status.message.metadata, indent=2)}""" + + status_message_section = f"""ID: {result.status.message.message_id} +Role: {result.status.message.role} +Task ID: {result.status.message.task_id} +Context ID: {result.status.message.context_id} +Message Parts: +{_NEW_LINE.join(status_parts_logs) if status_parts_logs else "No parts"}{status_metadata_section}""" + + # Build history section + history_section = "No history" + if _is_a2a_task(result) and result.history: + history_logs = [] + for i, message in enumerate(result.history): + message_parts_logs = [] + if message.parts: + for j, part in enumerate(message.parts): + part_log = build_message_part_log(part) + # Replace any internal newlines with indented newlines to maintain formatting + part_log_formatted = part_log.replace("\n", "\n ") + message_parts_logs.append(f" Part {j}: {part_log_formatted}") + + # Build message metadata section + message_metadata_section = "" + if message.metadata: + message_metadata_section = f""" + Metadata: + {json.dumps(message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}""" + + history_logs.append(f"""Message {i + 1}: + ID: {message.message_id} + Role: {message.role} + Task ID: {message.task_id} + Context ID: {message.context_id} + Message Parts: +{_NEW_LINE.join(message_parts_logs) if message_parts_logs else " No parts"}{message_metadata_section}""") + + history_section = _NEW_LINE.join(history_logs) + + return f""" +A2A Response: +----------------------------------------------------------- +Type: SUCCESS +Result Type: {result_type} +----------------------------------------------------------- +Result Details: +{_NEW_LINE.join(result_details)} +----------------------------------------------------------- +Status Message: +{status_message_section} +----------------------------------------------------------- +History: +{history_section} +----------------------------------------------------------- +Response ID: {resp.root.id} +JSON-RPC: {resp.root.jsonrpc} +----------------------------------------------------------- +""" diff --git a/trpc_agent_sdk/server/ag_ui/README.md b/trpc_agent_sdk/server/ag_ui/README.md new file mode 100644 index 000000000..9c28ebe2a --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/README.md @@ -0,0 +1,131 @@ +# AG-UI Server 实现说明 + +本目录提供 `trpc-agent` 与 [AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui) 的服务端桥接能力。核心目标是:将 `Runner.run_async(...)` 产生的事件流转换为 AG-UI 标准事件,并通过 HTTP/SSE 持续推送给前端。 + +## 1. 实现原理(高层) + +`trpc_agent_sdk.server.ag_ui` 的职责可以拆成三层: + +- **服务编排层**(`_plugin/`) + - `AgUiManager`:统一管理 service 生命周期,启动 FastAPI/Uvicorn + - `AgUiService`:注册 URI 与 `AgUiAgent`,提供 POST 流式接口 + - `AgUiServiceRegistry`:全局 service 注册表(单例) +- **协议桥接层**(`_core/_agui_agent.py`) + - 负责 AG-UI 请求解析、session 获取、执行状态跟踪、继续执行(tool result submission) + - 调用 `Runner.run_async(...)` 执行真实 agent +- **事件翻译层**(`_core/_event_translator.py`) + - 把 `trpc_agent_sdk.events.Event` 翻译为 AG-UI 事件(文本流、tool call、state snapshot 等) + - 处理 streaming 文本闭合、tool call 生命周期、一致性兜底(force close) + +## 2. 端到端流程图 + +```mermaid +flowchart TD + A[AG-UI Client\nPOST /agent_uri] --> B[AgUiService endpoint] + B --> C[AgUiAgent.run] + C --> D{tool result submission?} + + D -- No --> E[start new execution] + D -- Yes --> F[handle tool result\nresume/new execution] + + E --> G[SessionManager\nget_or_create_session] + F --> G + G --> H[Runner.run_async] + H --> I[TRPC Events] + I --> J[EventTranslator] + J --> K[AG-UI Events queue] + K --> L[StreamingResponse SSE] + L --> M[AG-UI Client] + + H --> N[LongRunningEvent] + N --> J + J --> O[Tool call events for HITL] + O --> M +``` + +## 3. 核心代码讲解 + +### 3.1 `AgUiManager`:服务聚合与启动 + +[AgUiManager](./_plugin/_manager.py) + +- `_build_agents()`:遍历所有 service,调用 `create_agents()`,并把 URI->Agent 映射挂到 manager +- `run()`:先 build,再 `uvicorn.run(...)` +- `close()`:遍历关闭所有 `AgUiAgent`(释放后台执行状态、清理任务) + +这层是“容器”,不关心协议细节。 + +### 3.2 `AgUiService`:URI 与流式接口绑定 + +[AgUiService](./_plugin/_service.py) + +- `add_agent(uri, agui_agent)`:注册 URI,同时在 FastAPI 动态加 POST 路由 +- `_ag_ui_agent_endpoint(...)`: + - 按 `Accept` 头创建 `EventEncoder` + - 找到对应 `AgUiAgent` + - 返回 `StreamingResponse(event_generator(...), media_type=...)` + +这层负责“把请求接进来并输出 SSE”。 + +### 3.3 `AgUiAgent`:协议桥接核心 + +`trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py` + +- `run(...)`: + - 判断是否 tool result submission(前端回传工具结果) + - 分流到 `_start_new_execution` 或 `_handle_tool_result_submission` +- `_ensure_session_exists(...)`:通过 `SessionManager` 建立/复用会话 +- 内部执行主链: + - `runner.run_async(...)` 拉取 TRPC 事件 + - `EventTranslator.translate(...)` 逐个翻译并入队 + - 最终 `force_close_streaming_message()` + state snapshot 兜底收尾 + +这层是“AG-UI <-> TRPC”的核心状态机。 + +### 3.4 `EventTranslator`:事件协议转换 + +[EventTranslator](./_core/_event_translator.py) + +- 文本事件:`TEXT_MESSAGE_START/CONTENT/END` +- 工具事件:`TOOL_CALL_START/ARGS/END/RESULT` +- 长任务工具:`translate_lro_function_calls(...)` +- 状态事件:`STATE_DELTA` / `STATE_SNAPSHOT` +- 一致性保障:`force_close_streaming_message()` 防止流中断后消息未闭合 + +这层确保前端严格收到 AG-UI 预期事件序列。 + +### 3.5 `SessionManager`:会话与状态增强 + +[SessionManager](./_core/_session_manager.py) + +- 在底层 session service 之上提供: + - timeout/cleanup + - 每用户会话限制 + - 状态读写/批量更新 + - 过期会话清理(含 HITL pending tool call 保护) + +这层为线上运行提供“会话治理能力”。 + +## 4. 示例入口(可直接参考) + +- 示例文档:[examples/agui/README.md](../../../examples/agui/README.md) +- 服务端启动:[examples/agui/run_server.py](../../../examples/agui/run_server.py) +- Runner 组装:[examples/agui/_agui_runner.py](../../../examples/agui/_agui_runner.py) + +建议阅读顺序: + +1. 先看 [examples/agui/run_server.py](../../../examples/agui/run_server.py)(如何启动) +2. 再看 [examples/agui/_agui_runner.py](../../../examples/agui/_agui_runner.py)(如何注册 service + uri) +3. 回到本目录看 `AgUiService` / `AgUiAgent`(协议桥接细节) + +## 5. 对外导出 API + +`trpc_agent_sdk.server.ag_ui` 当前对外导出: + +- `AgUiAgent` +- `AgUiUserFeedBack` +- `get_agui_http_req` +- `AgUiManager` +- `AgUiService` +- `get_agui_service_registry` + diff --git a/trpc_agent_sdk/server/ag_ui/__init__.py b/trpc_agent_sdk/server/ag_ui/__init__.py new file mode 100644 index 000000000..9942510f6 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/__init__.py @@ -0,0 +1,26 @@ +# 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. +"""AG-UI server for tRPC-Python framework.""" + +from ._core import AgUiAgent +from ._core import AgUiUserFeedBack +from ._core import get_agui_http_req +from ._plugin import AgUiManager +from ._plugin import AgUiService +from ._plugin import AgUiLangGraphEventTranslator +from ._plugin import AgUiTranslationContext +from ._plugin import get_agui_service_registry + +__all__ = [ + "AgUiAgent", + "AgUiUserFeedBack", + "get_agui_http_req", + "AgUiManager", + "AgUiService", + "AgUiLangGraphEventTranslator", + "AgUiTranslationContext", + "get_agui_service_registry", +] diff --git a/trpc_agent_sdk/server/ag_ui/_core/__init__.py b/trpc_agent_sdk/server/ag_ui/_core/__init__.py new file mode 100644 index 000000000..cc71f0099 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/__init__.py @@ -0,0 +1,63 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""TRPC Agent Middleware for AG-UI Protocol + +This middleware enables TRPC agents to be used with the AG-UI protocol. +""" + +from ._agui_agent import AgUiAgent +from ._client_proxy_tool import ClientProxyTool +from ._client_proxy_toolset import ClientProxyToolset +from ._converters import convert_ag_ui_messages_to_trpc +from ._converters import convert_json_patch_to_state +from ._converters import convert_message_content_to_parts +from ._converters import convert_state_to_json_patch +from ._converters import convert_trpc_event_to_ag_ui_message +from ._converters import create_error_message +from ._converters import extract_text_from_content +from ._endpoint import add_trpc_fastapi_endpoint +from ._endpoint import create_trpc_app +from ._event_translator import EventTranslator +from ._execution_state import ExecutionState +from ._feed_back_content import AgUiUserFeedBack +from ._http_req import get_agui_http_req +from ._http_req import set_agui_http_req +from ._session_manager import SessionManager + +__all__ = [ + "AgUiAgent", + "ClientProxyTool", + "ClientProxyToolset", + "convert_ag_ui_messages_to_trpc", + "convert_json_patch_to_state", + "convert_message_content_to_parts", + "convert_state_to_json_patch", + "convert_trpc_event_to_ag_ui_message", + "create_error_message", + "extract_text_from_content", + "add_trpc_fastapi_endpoint", + "create_trpc_app", + "EventTranslator", + "ExecutionState", + "AgUiUserFeedBack", + "get_agui_http_req", + "set_agui_http_req", + "SessionManager", +] diff --git a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py new file mode 100644 index 000000000..b9ca48a86 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py @@ -0,0 +1,1278 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Main AgUiAgent implementation for bridging AG-UI Protocol with TRPC Agent.""" + +import asyncio +import inspect +import json +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional + +from ag_ui.core import BaseEvent +from ag_ui.core import EventType +from ag_ui.core import RunAgentInput +from ag_ui.core import RunErrorEvent +from ag_ui.core import RunFinishedEvent +from ag_ui.core import RunStartedEvent +from ag_ui.core import SystemMessage +from ag_ui.core import ToolCallEndEvent +from ag_ui.core import ToolCallResultEvent +from starlette.requests import Request +from trpc_agent_sdk import cancel +from trpc_agent_sdk import types +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.configs import RunConfig as TRPCRunConfig +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.memory import BaseMemoryService +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import BaseSessionService +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import LongRunningFunctionTool +from trpc_agent_sdk.types import Content + +from ._client_proxy_toolset import ClientProxyToolset +from ._converters import convert_message_content_to_parts +from ._event_translator import EventTranslator +from ._execution_state import ExecutionState +from ._feed_back_content import AgUiUserFeedBack +from ._http_req import set_agui_http_req +from ._session_manager import SessionManager + + +class AgUiAgent: + """Middleware to bridge AG-UI Protocol with TRPC agents. + + This agent translates between the AG-UI protocol events and TRPC agent events, + managing sessions, state, and the lifecycle of TRPC agents. + """ + + def __init__( + self, + # TRPC Agent instance + trpc_agent: BaseAgent, + *, + # App identification + app_name: Optional[str] = None, + app_name_extractor: Optional[Callable[[RunAgentInput], str]] = None, + # User identification + user_id: Optional[str] = None, + # user_id_extractor: Function to extract user ID dynamically from input + user_id_extractor: Optional[Callable[[RunAgentInput], str]] = None, + # Storage ServicesD + session_service: Optional[BaseSessionService] = None, + # Memory service + memory_service: Optional[BaseMemoryService] = None, + # Configuration + run_config_factory: Optional[Callable[[RunAgentInput], TRPCRunConfig]] = None, + # Service configuration + use_in_memory_services: bool = True, + # Tool configuration + execution_timeout_seconds: int = 600, # 10 minutes + tool_timeout_seconds: int = 300, # 5 minutes + max_concurrent_executions: int = 10, + # Custom user feedback handler + user_feedback_handler: Optional[Callable[[AgUiUserFeedBack], Awaitable[None]]] = None, + # Session cleanup configuration + session_timeout_seconds: Optional[int] = 1200, + cleanup_interval_seconds: int = 300, # 5 minutes default + # Session management + max_sessions_per_user: Optional[int] = None, + # Auto cleanup + auto_cleanup: bool = True, + # Cancel wait timeout + cancel_wait_timeout: float = 3.0, + # Custom event translator for LangGraph events + event_translator: Optional[EventTranslatorBase[BaseEvent, Any]] = None, + ): + """Initialize the AgUiAgent. + + Args: + trpc_agent: The TRPC agent instance to use + app_name: Static application name for all requests + app_name_extractor: Function to extract app name dynamically from input + user_id: Static user ID for all requests + user_id_extractor: Function to extract user ID dynamically from input + session_service: Session management service (defaults to InMemorySessionService) + memory_service: Conversation memory and search service (also enables automatic session memory) + run_config_factory: Function to create RunConfig per request + use_in_memory_services: Use in-memory implementations for unspecified services + execution_timeout_seconds: Timeout for entire execution + tool_timeout_seconds: Timeout for individual tool calls + max_concurrent_executions: Maximum concurrent background executions + user_feedback_handler: Optional async callback function to handle user feedback after getting tool results. + Signature: async (content: AgUiUserFeedBack) -> None + Can modify session state via content.session. If session is modified, + it will be updated automatically. + cancel_wait_timeout: Timeout in seconds for waiting on cancel operation to complete (default: 3.0). + If not configured properly, the cancel operation may not execute successfully, + potentially causing streaming text to not be preserved in the session. + event_translator: Optional custom event translator for LangGraph events. If provided, events + created by LangGraphEventWriter will be processed by this translator instead + of the default EventTranslator. + """ + if app_name and app_name_extractor: + raise ValueError("Cannot specify both 'app_name' and 'app_name_extractor'") + + # app_name, app_name_extractor, or neither (use agent name as default) + + if user_id and user_id_extractor: + raise ValueError("Cannot specify both 'user_id' and 'user_id_extractor'") + + self._trpc_agent = trpc_agent + self._static_app_name = app_name + self._app_name_extractor = app_name_extractor + self._static_user_id = user_id + self._user_id_extractor = user_id_extractor + self._run_config_factory = run_config_factory or self._default_run_config + + # Initialize services with intelligent defaults + if use_in_memory_services: + self._memory_service = memory_service or InMemoryMemoryService() + else: + # Require explicit services for production + self._memory_service = memory_service + + # Session lifecycle management - use singleton + # Use provided session service or create default based on use_in_memory_services + if session_service is None: + session_service = InMemorySessionService() # Default for both dev and production + + self._session_manager = SessionManager.get_instance( + session_service=session_service, + memory_service=self._memory_service, # Pass memory service for automatic session memory + session_timeout_seconds=session_timeout_seconds, # 20 minutes default + cleanup_interval_seconds=cleanup_interval_seconds, + max_sessions_per_user=max_sessions_per_user, # No limit by default + auto_cleanup=auto_cleanup, # Enable by default + ) + + # Tool execution tracking + self._active_executions: Dict[str, ExecutionState] = {} + self._execution_timeout = execution_timeout_seconds + self._tool_timeout = tool_timeout_seconds + self._max_concurrent = max_concurrent_executions + self._execution_lock = asyncio.Lock() + self._user_feedback_handler = user_feedback_handler + self._cancel_wait_timeout = cancel_wait_timeout + self._custom_event_translator = event_translator + + # Session lookup cache for efficient session ID to metadata mapping + # Maps session_id -> {"app_name": str, "user_id": str} + self._session_lookup_cache: Dict[str, Dict[str, str]] = {} + + # Event translator will be created per-session for thread safety + + # Cleanup is managed by the session manager + # Will start when first async operation runs + + def _get_session_metadata(self, session_id: str) -> Optional[Dict[str, str]]: + """Get session metadata (app_name, user_id) for a session ID efficiently. + + Args: + session_id: The session ID to lookup + + Returns: + Dictionary with app_name and user_id, or None if not found + """ + # Try cache first for O(1) lookup + if session_id in self._session_lookup_cache: + return self._session_lookup_cache[session_id] + + # Fallback to linear search if not in cache (for existing sessions) + # This maintains backward compatibility + try: + for uid, keys in self._session_manager._user_sessions.items(): + for key in keys: + if key.endswith(f":{session_id}"): + app_name = key.split(":", 1)[0] + metadata = {"app_name": app_name, "user_id": uid} + # Cache for future lookups + self._session_lookup_cache[session_id] = metadata + return metadata + except Exception as ex: # pylint: disable=broad-except + logger.error("Error during session metadata lookup for %s: %s", session_id, ex) + + return None + + def get_app_name(self, input: RunAgentInput) -> str: + """Resolve app name with clear precedence.""" + if self._static_app_name: + return self._static_app_name + elif self._app_name_extractor: + return self._app_name_extractor(input) + else: + return self._default_app_extractor(input) + + def _default_app_extractor(self, input: RunAgentInput) -> str: + """Default app extraction logic - use agent name directly.""" + # Use the TRPC agent's name as app name + try: + return self._trpc_agent.name + except Exception as ex: # pylint: disable=broad-except + logger.warning("Could not get agent name for app_name, using default: %s", ex) + return "AG-UI TRPC Agent" + + def get_user_id(self, input: RunAgentInput) -> str: + """Resolve user ID with clear precedence.""" + if self._static_user_id: + return self._static_user_id + elif self._user_id_extractor: + return self._user_id_extractor(input) + else: + return self._default_user_extractor(input) + + def _default_user_extractor(self, input: RunAgentInput) -> str: + """Default user extraction logic.""" + # Use thread_id as default (assumes thread per user) + return f"thread_user_{input.thread_id}" + + async def _add_pending_tool_call_with_context(self, session_id: str, tool_call_id: str, app_name: str, + user_id: str): + """Add a tool call to the session's pending list for HITL tracking. + + Args: + session_id: The session ID (thread_id) + tool_call_id: The tool call ID to track + app_name: App name (for session lookup) + user_id: User ID (for session lookup) + """ + logger.debug("Adding pending tool call %s for session %s, app_name=%s, user_id=%s", tool_call_id, session_id, + app_name, user_id) + try: + # Get current pending calls using SessionManager + pending_calls = await self._session_manager.get_state_value(session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + default=[]) + + # Add new tool call if not already present + if tool_call_id not in pending_calls: + pending_calls.append(tool_call_id) + + # Update the state using SessionManager + success = await self._session_manager.set_state_value( + session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + value=pending_calls, + ) + + if success: + logger.debug("Added tool call %s to session %s pending list", tool_call_id, session_id) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to add pending tool call %s to session %s: %s", tool_call_id, session_id, ex) + + async def _remove_pending_tool_call(self, session_id: str, tool_call_id: str): + """Remove a tool call from the session's pending list. + + Uses efficient session lookup to find the session without needing explicit app_name/user_id. + + Args: + session_id: The session ID (thread_id) + tool_call_id: The tool call ID to remove + """ + try: + # Use efficient session metadata lookup + metadata = self._get_session_metadata(session_id) + + if metadata: + app_name = metadata["app_name"] + user_id = metadata["user_id"] + + # Get current pending calls using SessionManager + pending_calls = await self._session_manager.get_state_value(session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + default=[]) + + # Remove tool call if present + if tool_call_id in pending_calls: + pending_calls.remove(tool_call_id) + + # Update the state using SessionManager + success = await self._session_manager.set_state_value( + session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + value=pending_calls, + ) + + if success: + logger.debug("Removed tool call %s from session %s pending list", tool_call_id, session_id) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to remove pending tool call %s from session %s: %s", tool_call_id, session_id, ex) + + async def _has_pending_tool_calls(self, session_id: str) -> bool: + """Check if session has pending tool calls (HITL scenario). + + Args: + session_id: The session ID (thread_id) + + Returns: + True if session has pending tool calls + """ + try: + # Use efficient session metadata lookup + metadata = self._get_session_metadata(session_id) + + if metadata: + app_name = metadata["app_name"] + user_id = metadata["user_id"] + + # Get pending calls using SessionManager + pending_calls = await self._session_manager.get_state_value(session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + default=[]) + return len(pending_calls) > 0 + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to check pending tool calls for session %s: %s", session_id, ex) + + return False + + def _default_run_config(self, input: RunAgentInput) -> TRPCRunConfig: + """Create default RunConfig with streaming enabled.""" + return TRPCRunConfig(streaming=True) + + def _extract_long_running_tool_names(self, agent: BaseAgent) -> List[str]: + """Extract long-running tool names from the TRPC agent. + + Args: + agent: The TRPC agent to extract tool names from + + Returns: + List of long-running tool names + """ + + long_running_tool_names = [] + + if hasattr(agent, "tools") and agent.tools: + # Handle both single tool and list of tools + tools = agent.tools if isinstance(agent.tools, (list, tuple)) else [agent.tools] + + for tool in tools: + # Check if it's a LongRunningFunctionTool + if isinstance(tool, LongRunningFunctionTool): + long_running_tool_names.append(tool.name) + # Check if it's a ClientProxyToolset (all its tools are long-running) + elif isinstance(tool, ClientProxyToolset): + # Extract tool names from the toolset + for ag_ui_tool in tool.ag_ui_tools: + long_running_tool_names.append(ag_ui_tool.name) + logger.debug("Added long-running tool from ClientProxyToolset: %s", ag_ui_tool.name) + + logger.debug("Extracted long-running tool names: %s", long_running_tool_names) + return long_running_tool_names + + def _create_runner(self, agent: BaseAgent, user_id: str, app_name: str) -> Runner: + """Create a new runner instance.""" + return Runner( + app_name=app_name, + agent=agent, + session_service=self._session_manager._session_service, + memory_service=self._memory_service, + ) + + async def cancel_run( + self, + session_id: str, + app_name: str, + user_id: str, + ) -> bool: + """Cancel an ongoing run for the specified session. + + Called when the SSE connection is closed by the client. + + Args: + session_id: The thread/session ID + app_name: Application name + user_id: User identifier + + Returns: + True if cancellation was triggered successfully + """ + + # Use configured cancel_wait_timeout + timeout = self._cancel_wait_timeout + + logger.debug("Cancelling run for session %s", session_id) + + # Cancel the TRPC run using the existing mechanism + cleanup_event = await cancel.cancel_run(app_name, user_id, session_id) + + if cleanup_event is None: + # No active run found - might have already completed + logger.debug("No active run to cancel for session %s", session_id) + return False + + try: + # Wait for cleanup to complete + await asyncio.wait_for(cleanup_event.wait(), timeout=timeout) + logger.info("Cancel completed for session %s", session_id) + except asyncio.TimeoutError: + logger.warning("Cancel timeout for session %s", session_id) + + # Clear pending tool calls from session state + try: + await self._session_manager.set_state_value( + session_id=session_id, + app_name=app_name, + user_id=user_id, + key="pending_tool_calls", + value=[], # Clear all pending tool calls + ) + logger.debug("Cleared pending tool calls for session %s", session_id) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to clear pending tool calls: %s", ex) + + # Also cancel the ExecutionState task if still running + async with self._execution_lock: + if session_id in self._active_executions: + execution = self._active_executions[session_id] + await execution.cancel() + del self._active_executions[session_id] + logger.debug("Cancelled and removed execution state for session %s", session_id) + + return True + + async def run(self, + input: RunAgentInput, + http_request: Optional[Request] = None) -> AsyncGenerator[BaseEvent, None]: + """Run the TRPC agent with client-side tool support. + + All client-side tools are long-running. For tool result submissions, + we continue existing executions. For new requests, we start new executions. + TRPC sessions handle conversation continuity and tool result processing. + + Args: + input: The AG-UI run input + http_request: Optional HTTP request for this run. + + Yields: + AG-UI protocol events + """ + # Check if this is a tool result submission for an existing execution + if self._is_tool_result_submission(input): + # Handle tool results for existing execution + async for event in self._handle_tool_result_submission(input, http_request): + yield event + else: + # Start new execution for regular requests + async for event in self._start_new_execution(input, http_request): + yield event + + async def _ensure_session_exists(self, app_name: str, user_id: str, session_id: str, initial_state: dict): + """Ensure a session exists, creating it if necessary via session manager.""" + try: + # Use session manager to get or create session + trpc_session = await self._session_manager.get_or_create_session( + session_id=session_id, + app_name=app_name, # Use app_name for session management + user_id=user_id, + initial_state=initial_state, + ) + + # Update session lookup cache for efficient session ID to metadata mapping + self._session_lookup_cache[session_id] = {"app_name": app_name, "user_id": user_id} + + logger.debug("Session ready: %s for user: %s", session_id, user_id) + return trpc_session + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to ensure session %s: %s", session_id, ex) + raise + + async def _convert_latest_message(self, input: RunAgentInput) -> Optional[Content]: + """Convert the latest user message to TRPC Content format. + + Supports both simple string content and multi-model content (text + images). + """ + + if not input.messages: + return None + + # Get the latest user message + for message in reversed(input.messages): + if getattr(message, "role", None) == "user" and getattr(message, "content", None): + parts = convert_message_content_to_parts(getattr(message, "content", None)) + if not parts: + return None + return types.Content(role="user", parts=parts) + + return None + + def _is_tool_result_submission(self, input: RunAgentInput) -> bool: + """Check if this request contains tool results. + + Args: + input: The run input + + Returns: + True if the last message is a tool result + """ + if not input.messages: + return False + + last_message = input.messages[-1] + return hasattr(last_message, "role") and last_message.role == "tool" + + async def _handle_tool_result_submission(self, + input: RunAgentInput, + http_request: Optional[Request] = None) -> AsyncGenerator[BaseEvent, None]: + """Handle tool result submission for existing execution. + + Args: + input: The run input containing tool results + http_request: Optional HTTP request for this run + + Yields: + AG-UI events from continued execution + """ + thread_id = input.thread_id + + # Extract tool results that is send by the frontend + tool_results = await self._extract_tool_results(input) + + # if the tool results are not sent by the fronted then call the tool function + if not tool_results: + logger.error("Tool result submission without tool results for thread %s", thread_id) + yield RunErrorEvent(type=EventType.RUN_ERROR, + message="No tool results found in submission", + code="NO_TOOL_RESULTS") + return + + try: + # Check if tool result matches any pending tool calls for better debugging + for tool_result in tool_results: + tool_call_id = tool_result["message"].tool_call_id + has_pending = await self._has_pending_tool_calls(thread_id) + + if has_pending: + # Could add more specific check here for the exact tool_call_id + # but for now just log that we're processing a tool result while tools are pending + logger.debug("Processing tool result %s for thread %s with pending tools", tool_call_id, thread_id) + # Remove from pending tool calls now that we're processing it + await self._remove_pending_tool_call(thread_id, tool_call_id) + else: + # No pending tools - this could be a stale result or from a different session + logger.warning("No pending tool calls found for tool result %s in thread %s", tool_call_id, + thread_id) + + # Since all tools are long-running, all tool results are standalone + # and should start new executions with the tool results + logger.info("Starting new execution for tool result in thread %s", thread_id) + async for event in self._start_new_execution(input, http_request): + yield event + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error handling tool results: %s", ex, exc_info=True) + yield RunErrorEvent( + type=EventType.RUN_ERROR, + message=f"Failed to process tool results: {str(ex)}", + code="TOOL_RESULT_PROCESSING_ERROR", + ) + + async def _extract_tool_results(self, input: RunAgentInput) -> List[Dict]: + """Extract tool messages with their names from input. + + Only extracts the most recent tool message to avoid accumulation issues + where multiple tool results are sent to the LLM causing API errors. + + Args: + input: The run input + + Returns: + List of dicts containing tool name and message (single item for most recent) + """ + # Create a mapping of tool_call_id to tool name + tool_call_map = {} + for message in input.messages: + if hasattr(message, "tool_calls") and message.tool_calls: + for tool_call in message.tool_calls: + tool_call_map[tool_call.id] = tool_call.function.name + + # Find the most recent tool message (should be the last one in a tool result submission) + most_recent_tool_message = None + for message in reversed(input.messages): + if hasattr(message, "role") and message.role == "tool": + most_recent_tool_message = message + break + + if most_recent_tool_message: + tool_name = tool_call_map.get(most_recent_tool_message.tool_call_id, "unknown") + + # Debug: Log the extracted tool message + logger.debug("Extracted most recent ToolMessage: role=%s, tool_call_id=%s, content='%s'", + most_recent_tool_message.role, most_recent_tool_message.tool_call_id, + most_recent_tool_message.content) + + return [{"tool_name": tool_name, "message": most_recent_tool_message}] + + return [] + + async def _stream_events(self, execution: ExecutionState) -> AsyncGenerator[BaseEvent, None]: + """Stream events from execution queue. + + Args: + execution: The execution state + + Yields: + AG-UI events from the queue + """ + logger.debug("Starting _stream_events for thread %s, queue ID: %s", execution.thread_id, + id(execution.event_queue)) + event_count = 0 + timeout_count = 0 + + while True: + try: + logger.debug("Waiting for event from queue (thread %s, queue size: %s)", execution.thread_id, + execution.event_queue.qsize()) + + # Wait for event with timeout + event = await asyncio.wait_for(execution.event_queue.get(), timeout=1.0) # Check every second + + event_count += 1 + logger.debug("Got event #%s from queue: %s (thread %s)", event_count, + type(event).__name__ if event else 'None', execution.thread_id) + + if event is None: + # Execution complete + execution.is_complete = True + logger.debug("Execution complete for thread %s after %s events", execution.thread_id, event_count) + break + + logger.debug("Streaming event #%s: %s (thread %s)", event_count, + type(event).__name__, execution.thread_id) + yield event + + except asyncio.TimeoutError: + timeout_count += 1 + logger.debug("Timeout #%s waiting for events (thread %s, task done: %s, queue size: %s)", timeout_count, + execution.thread_id, execution.task.done(), execution.event_queue.qsize()) + + # Check if execution is stale + if execution.is_stale(self._execution_timeout): + logger.error("Execution timed out for thread %s", execution.thread_id) + yield RunErrorEvent(type=EventType.RUN_ERROR, + message="Execution timed out", + code="EXECUTION_TIMEOUT") + break + + # Check if task is done + if execution.task.done(): + # Task completed but didn't send None + execution.is_complete = True + try: + task_result = execution.task.result() + logger.debug("Task completed with result: %s (thread %s)", task_result, execution.thread_id) + except Exception as ex: # pylint: disable=broad-except + logger.debug("Task completed with exception: %s (thread %s)", ex, execution.thread_id) + + # Wait a bit more in case there are events still coming + logger.debug( + "Task done but no None signal - checking queue one more time (thread %s, queue size: %s)", + execution.thread_id, execution.event_queue.qsize()) + if execution.event_queue.qsize() > 0: + logger.debug("Found %s events in queue after task completion, continuing...", + execution.event_queue.qsize()) + continue + + logger.debug("Task completed without sending None signal (thread %s)", execution.thread_id) + break + + async def _is_hitl_text_scenario(self, thread_id: str, app_name: str, user_id: str) -> Optional[types.FunctionCall]: + """Check if this is a HITL scenario with text instead of tool_result. + + HITL pattern: last two events in session are (function_call, function_response) + and the function IDs match. + + Args: + thread_id: Session/thread ID + app_name: Application name + user_id: User identifier + + Returns: + The FunctionCall object if HITL pattern detected, None otherwise + """ + 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 or len(session.events) < 2: + return None + + # Check last two events + last_event = session.events[-1] + second_last_event = session.events[-2] + + # Check if second last event has function_call + function_call = None + if second_last_event.content and second_last_event.content.parts: + for part in reversed(second_last_event.content.parts): + if part.function_call: + function_call = part.function_call + break + + # Check if last event has function_response + function_response = None + if last_event.content and last_event.content.parts: + for part in reversed(last_event.content.parts): + if part.function_response: + function_response = part.function_response + break + + # HITL pattern: (function_call, function_response) with matching IDs + if function_call and function_response: + if function_call.id == function_response.id: + return function_call + + return None + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error checking HITL scenario: %s", ex, exc_info=True) + return None + + async def _start_new_execution(self, + input: RunAgentInput, + http_request: Optional[Request] = None) -> AsyncGenerator[BaseEvent, None]: + """Start a new TRPC execution with tool support. + + Args: + input: The run input + http_request: Optional HTTP request for this run + + Yields: + AG-UI events from the execution + """ + try: + # Emit RUN_STARTED + logger.debug("Emitting RUN_STARTED for thread %s, run %s", input.thread_id, input.run_id) + yield RunStartedEvent(type=EventType.RUN_STARTED, thread_id=input.thread_id, run_id=input.run_id) + + # Check concurrent execution limit + async with self._execution_lock: + if len(self._active_executions) >= self._max_concurrent: + # Clean up stale executions + await self._cleanup_stale_executions() + + if len(self._active_executions) >= self._max_concurrent: + raise RuntimeError(f"Maximum concurrent executions ({self._max_concurrent}) reached") + + # Check if there's an existing execution for this thread and wait for it + existing_execution = self._active_executions.get(input.thread_id) + + # If there was an existing execution, wait for it to complete + if existing_execution and not existing_execution.is_complete: + logger.debug("Waiting for existing execution to complete for thread %s", input.thread_id) + try: + await existing_execution.task + except Exception as ex: # pylint: disable=broad-except + logger.debug("Previous execution completed with error: %s", ex) + + # Start background execution + execution = await self._start_background_execution(input, http_request) + + # Store execution (replacing any previous one) + async with self._execution_lock: + self._active_executions[input.thread_id] = execution + + # Stream events and track tool calls + logger.debug("Starting to stream events for execution %s", execution.thread_id) + has_tool_calls = False + has_error = False + tool_call_ids = [] + + logger.debug("About to iterate over _stream_events for execution %s", execution.thread_id) + async for event in self._stream_events(execution): + # Track tool calls for HITL scenarios + if isinstance(event, ToolCallEndEvent): + logger.debug("Detected ToolCallEndEvent with id: %s", event.tool_call_id) + has_tool_calls = True + tool_call_ids.append(event.tool_call_id) + + # backend tools will always emit ToolCallResultEvent + # If it is a backend tool then we don't need to add the tool_id in pending_tools + if isinstance(event, ToolCallResultEvent) and event.tool_call_id in tool_call_ids: + logger.debug("Detected ToolCallResultEvent with id: %s", event.tool_call_id) + tool_call_ids.remove(event.tool_call_id) + + # Track if a RUN_ERROR event has been emitted to avoid sending RUN_FINISHED afterwards + if isinstance(event, RunErrorEvent): + has_error = True + + logger.debug("Yielding event: %s", type(event).__name__) + yield event + + logger.debug("Finished iterating over _stream_events for execution %s", execution.thread_id) + + # If we found tool calls, add them to session state BEFORE cleanup + if has_tool_calls: + app_name = self.get_app_name(input) + user_id = self.get_user_id(input) + for tool_call_id in tool_call_ids: + await self._add_pending_tool_call_with_context( + execution.thread_id, + tool_call_id, + app_name, + user_id, + ) + logger.debug("Finished streaming events for execution %s", execution.thread_id) + + # Emit RUN_FINISHED only if no error occurred during execution + if has_error: + logger.debug("Skipping RUN_FINISHED for thread %s due to previous RUN_ERROR", input.thread_id) + else: + logger.debug("Emitting RUN_FINISHED for thread %s, run %s", input.thread_id, input.run_id) + yield RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id=input.thread_id, run_id=input.run_id) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error in new execution: %s", ex, exc_info=True) + yield RunErrorEvent(type=EventType.RUN_ERROR, message=str(ex), code="EXECUTION_ERROR") + finally: + # Clean up execution if complete and no pending tool calls (HITL scenarios) + async with self._execution_lock: + if input.thread_id in self._active_executions: + execution = self._active_executions[input.thread_id] + execution.is_complete = True + + # Check if session has pending tool calls before cleanup + has_pending = await self._has_pending_tool_calls(input.thread_id) + if not has_pending: + del self._active_executions[input.thread_id] + logger.debug("Cleaned up execution for thread %s", input.thread_id) + else: + logger.info("Preserving execution for thread %s - has pending tool calls (HITL scenario)", + input.thread_id) + + async def _start_background_execution(self, + input: RunAgentInput, + http_request: Optional[Request] = None) -> ExecutionState: + """Start TRPC execution in background with tool support. + + Args: + input: The run input + http_request: Optional HTTP request for this run + + Returns: + ExecutionState tracking the background execution + """ + event_queue = asyncio.Queue() + logger.debug("Created event queue %s for thread %s", id(event_queue), input.thread_id) + # Extract necessary information + user_id = self.get_user_id(input) + app_name = self.get_app_name(input) + + # Prepare agent modifications (SystemMessage and tools) + agent_updates = {} + + # Handle SystemMessage if it's the first message - append to agent instructions + if input.messages and isinstance(input.messages[0], SystemMessage): + system_content = input.messages[0].content + if system_content: + current_instruction = getattr(self._trpc_agent, "instruction", "") + + if callable(current_instruction): + # Handle instructions provider + if inspect.iscoroutinefunction(current_instruction): + # Async instruction provider + async def instruction_provider_wrapper_async(*args, **kwargs): + instructions = system_content + original_instructions = await current_instruction(*args, **kwargs) or "" + if original_instructions: + instructions = f"{original_instructions}\n\n{instructions}" + return instructions + + new_instruction = instruction_provider_wrapper_async + else: + # Sync instruction provider + def instruction_provider_wrapper_sync(*args, **kwargs): + instructions = system_content + original_instructions = current_instruction(*args, **kwargs) or "" + if original_instructions: + instructions = f"{original_instructions}\n\n{instructions}" + return instructions + + new_instruction = instruction_provider_wrapper_sync + + logger.debug("Will wrap callable InstructionProvider and append SystemMessage: '%s...'", + system_content[:100]) + else: + # Handle string instructions + if current_instruction: + new_instruction = f"{current_instruction}\n\n{system_content}" + else: + new_instruction = system_content + logger.debug("Will append SystemMessage to string instructions: '%s...'", system_content[:100]) + + agent_updates["instruction"] = new_instruction + + # Create dynamic toolset if tools provided and prepare tool updates + toolset = None + if input.tools: + + # Get existing tools from the agent + existing_tools = [] + agent_tools = getattr(self._trpc_agent, "tools", []) + if agent_tools: + if isinstance(agent_tools, (list, tuple)): + existing_tools = list(agent_tools) + else: + existing_tools = [agent_tools] + + # if same tool is defined in frontend and backend then agent will only use the backend tool + input_tools = [] + for input_tool in input.tools: + # Check if this input tool's name matches any existing tool + # Also exclude this specific tool call "transfer_to_agent" + # which is used internally by the trpc to handoff to other agents + if (not any( + hasattr(existing_tool, "__name__") and input_tool.name == existing_tool.__name__ + for existing_tool in existing_tools) and input_tool.name != "transfer_to_agent"): + input_tools.append(input_tool) + + toolset = ClientProxyToolset(ag_ui_tools=input_tools, event_queue=event_queue) + + # Combine existing tools with our proxy toolset + combined_tools = existing_tools + [toolset] + agent_updates["tools"] = combined_tools + logger.debug("Will combine %s existing tools with proxy toolset", len(existing_tools)) + + # Create a single copy of the agent with all updates if any modifications needed + agent = self._trpc_agent + if agent_updates: + agent = self._trpc_agent.model_copy(update=agent_updates) + logger.debug("Created modified agent copy with updates: %s", list(agent_updates.keys())) + + # Create background task + logger.debug("Creating background task for thread %s", input.thread_id) + task = asyncio.create_task( + self._run_trpc_in_background( + input=input, + agent=agent, + user_id=user_id, + app_name=app_name, + event_queue=event_queue, + http_request=http_request, + )) + logger.debug("Background task created for thread %s: %s", input.thread_id, task) + + return ExecutionState(task=task, thread_id=input.thread_id, event_queue=event_queue) + + async def _run_trpc_in_background(self, + input: RunAgentInput, + agent: BaseAgent, + user_id: str, + app_name: str, + event_queue: asyncio.Queue, + http_request: Optional[Request] = None): + """Run TRPC agent in background, emitting events to queue. + + Args: + input: The run input + agent: The TRPC agent to run (already prepared with tools and SystemMessage) + user_id: User ID + app_name: App name + event_queue: Queue for emitting events + http_request: Optional HTTP request for this run + """ + try: + # Agent is already prepared with tools and SystemMessage instructions (if any) + # from _start_background_execution, so no additional agent copying needed here + + # Create runner + runner = self._create_runner(agent=agent, user_id=user_id, app_name=app_name) + + # Create RunConfig + run_config = self._run_config_factory(input) + if http_request: + set_agui_http_req(run_config, http_request) + + # Ensure session exists + await self._ensure_session_exists(app_name, user_id, input.thread_id, input.state) + + # this will always update the backend states with the frontend states + # Recipe Demo Example: if there is a state "salt" in the ingredients state and in frontend user + # remove this salt state using UI from the ingredients list then our backend should also update + # these state changes as well to sync both the states + await self._session_manager.update_session_state(input.thread_id, app_name, user_id, input.state) + + # Convert messages + # only use this new_message if there is no tool response from the user + new_message = await self._convert_latest_message(input) + + # Check if this is HITL scenario: last two events are (function_call, function_response) + # and user sent text instead of tool_result. Returns the function_call if HITL detected. + hitl_function_call = await self._is_hitl_text_scenario(input.thread_id, app_name, user_id) + + # 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) + parts = [] + for tool_msg in tool_results: + tool_call_id = tool_msg["message"].tool_call_id + content = tool_msg["message"].content + + # Debug: Log the actual tool message content we received + logger.debug("Received tool result for call %s: content='%s', type=%s", tool_call_id, content, + type(content)) + + # Call user feedback handler if provided (may modify content) + content = await self._execute_user_feedback_handler( + tool_name=tool_msg["tool_name"], + tool_message=content, + thread_id=input.thread_id, + app_name=app_name, + user_id=user_id, + ) + + # Parse JSON content, handling empty or invalid JSON gracefully + try: + if content and content.strip(): + result = json.loads(content) + else: + # Handle empty content as a success with empty result + result = {"success": True, "result": None} + logger.warning("Empty tool result content for tool call %s, using empty success result", + tool_call_id) + except json.JSONDecodeError as ex: + # Handle invalid JSON by providing detailed error result + logger.debug("Invalid JSON %s in tool result: %s", content, ex) + # At this time, directly use content as the result + result = {"content": content} + + updated_function_response_part = types.Part(function_response=types.FunctionResponse( + id=tool_call_id, + name=tool_msg["tool_name"], + response=result, + )) + parts.append(updated_function_response_part) + new_message = types.Content(parts=parts, role="user") + elif hitl_function_call: + # Handle HITL scenario where frontend sent text instead of tool_result + # Convert the user text to function_response + # hitl_function_call already contains the function_call from the session + if new_message and new_message.parts: + # Extract user text + user_text = "".join(part.text for part in new_message.parts if part.text) + tool_name = hitl_function_call.name + tool_call_id = hitl_function_call.id + + # Call user feedback handler if provided + content = await self._execute_user_feedback_handler( + tool_name=tool_name, + tool_message=user_text, + thread_id=input.thread_id, + app_name=app_name, + user_id=user_id, + ) + + # Convert text to function_response format + # Try to parse as JSON, otherwise wrap in a dict + try: + if content and content.strip(): + result = json.loads(content) + # content="1" is a valid json, but we expect a dict + if not isinstance(result, dict): + result = {"content": result} + else: + result = {"content": user_text} + except json.JSONDecodeError: + # Not JSON, wrap the text content + result = {"content": content if content else user_text} + + # Create function_response with same ID as function_call + # This will override the previous function_response in the session + function_response_part = types.Part(function_response=types.FunctionResponse( + id=tool_call_id, + name=tool_name, + response=result, + )) + + 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) + + # Create event translator + event_translator = EventTranslator(long_running_tool_names=long_running_tool_names) + + # Validate new_message before running agent + if not new_message or not new_message.parts: + error_msg = "No user message found in request" + logger.error(error_msg) + error_event = RunErrorEvent( + type=EventType.RUN_ERROR, + message=error_msg, + code="NO_MESSAGE_ERROR", + ) + await event_queue.put(error_event) + return + + # Run TRPC agent + async for trpc_event in runner.run_async(user_id=user_id, + session_id=input.thread_id, + new_message=new_message, + run_config=run_config): + if not isinstance(trpc_event, LongRunningEvent): + # Check if custom translator should handle this event + if self._custom_event_translator and self._custom_event_translator.need_translate(trpc_event): + # Import context class here to avoid circular imports + from .._plugin._langgraph_event_translator import AgUiTranslationContext + translator_context = AgUiTranslationContext(thread_id=input.thread_id, run_id=input.run_id) + async for ag_ui_event in self._custom_event_translator.translate( + trpc_event, translator_context): + await event_queue.put(ag_ui_event) + else: + # Existing behavior for regular events + async for ag_ui_event in event_translator.translate(trpc_event, input.thread_id, input.run_id): + logger.debug("Emitting event to queue: %s (thread %s, queue size before: %s)", + type(ag_ui_event).__name__, input.thread_id, event_queue.qsize()) + await event_queue.put(ag_ui_event) + logger.debug("Event queued: %s (thread %s, queue size after: %s)", + type(ag_ui_event).__name__, input.thread_id, event_queue.qsize()) + else: + # LongRunning Tool events are usually emitted in final response + async for ag_ui_event in event_translator.translate_lro_function_calls(trpc_event): + await event_queue.put(ag_ui_event) + logger.debug("Event queued: %s (thread %s, queue size after: %s)", + type(ag_ui_event).__name__, input.thread_id, event_queue.qsize()) + + # Force close any streaming messages + async for ag_ui_event in event_translator.force_close_streaming_message(): + await event_queue.put(ag_ui_event) + # moving states snapshot events after the text event closure + # to avoid this error https://github.com/Contextable/ag-ui/issues/28 + final_state = await self._session_manager.get_session_state(input.thread_id, app_name, user_id) + if final_state: + from datetime import datetime + + current_timestamp = datetime.now().timestamp() + ag_ui_event = event_translator._create_state_snapshot_event(final_state, current_timestamp) + await event_queue.put(ag_ui_event) + # Signal completion - TRPC execution is done + logger.debug("Background task sending completion signal for thread %s", input.thread_id) + await event_queue.put(None) + logger.debug("Background task completion signal sent for thread %s", input.thread_id) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Background execution error: %s", ex, exc_info=True) + # Put error in queue + await event_queue.put( + RunErrorEvent(type=EventType.RUN_ERROR, message=str(ex), code="BACKGROUND_EXECUTION_ERROR")) + await event_queue.put(None) + finally: + # Background task cleanup completed + # Note: toolset cleanup is handled by garbage collection + # since toolset is now embedded in the agent's tools + pass + + async def _execute_user_feedback_handler(self, tool_name: str, tool_message: str, thread_id: str, app_name: str, + user_id: str) -> str: + """Execute the user feedback handler if configured. + + Args: + tool_name: Name of the tool that was executed + tool_message: Content/result from the tool execution + thread_id: Current session/thread ID + app_name: Application name + user_id: User identifier + + Returns: + The potentially modified tool_message + """ + if not self._user_feedback_handler: + return tool_message + + try: + # Get session for handler + session = await self._session_manager._session_service.get_session(session_id=thread_id, + app_name=app_name, + user_id=user_id) + + if not session: + logger.warning("Session %s not found for user feedback handler", thread_id) + return tool_message + + # Create feedback content + feedback_content = AgUiUserFeedBack( + session=session, + tool_name=tool_name, + tool_message=tool_message, + ) + + # Call user feedback handler + await self._user_feedback_handler(feedback_content) + + # If session was modified, update it + if feedback_content.check_session_modified(): + await self._session_manager._session_service.update_session(session) + logger.debug("Updated session %s via user feedback handler", thread_id) + + # Return potentially modified tool_message + return feedback_content.tool_message + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error in user feedback handler: %s", ex, exc_info=True) + return tool_message + + async def _cleanup_stale_executions(self): + """Clean up stale executions.""" + stale_threads = [] + + for thread_id, execution in self._active_executions.items(): + if execution.is_stale(self._execution_timeout): + stale_threads.append(thread_id) + + for thread_id in stale_threads: + execution = self._active_executions.pop(thread_id) + await execution.cancel() + logger.info("Cleaned up stale execution for thread %s", thread_id) + + async def close(self): + """Clean up resources including active executions.""" + # Cancel all active executions + async with self._execution_lock: + for execution in self._active_executions.values(): + await execution.cancel() + self._active_executions.clear() + + # Clear session lookup cache + self._session_lookup_cache.clear() + + # Stop session manager cleanup task + await self._session_manager.stop_cleanup_task() diff --git a/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_tool.py b/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_tool.py new file mode 100644 index 000000000..608780edf --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_tool.py @@ -0,0 +1,153 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Client-side proxy tool implementation for AG-UI protocol tools.""" + +import asyncio +import inspect +from typing import Any +from typing import Dict +from typing import Optional + +from ag_ui.core import Tool as AGUITool +from trpc_agent_sdk import types +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.tools import LongRunningFunctionTool + + +class ClientProxyTool(LongRunningFunctionTool): + """A proxy tool that bridges AG-UI protocol tools to TRPC Agent. + + This tool appears as a normal TRPC tool to the agent, but when executed, + it emits AG-UI protocol events and waits for the client to execute + the actual tool and return results. + + Inherits from LongRunningFunctionTool for proper TRPC long-running behavior. + """ + + def __init__(self, ag_ui_tool: AGUITool, event_queue: asyncio.Queue): + """Initialize the client proxy tool. + + Args: + ag_ui_tool: The AG-UI tool definition + event_queue: Queue to emit AG-UI events + """ + self.ag_ui_tool = ag_ui_tool + self.event_queue = event_queue + + # Create dynamic function with proper parameter signatures for TRPC inspection + # This allows TRPC to extract parameters from user requests correctly + sig_params = [] + + # Extract parameters from AG-UI tool schema + parameters = ag_ui_tool.parameters + if isinstance(parameters, dict) and "properties" in parameters: + for param_name in parameters["properties"].keys(): + # Create parameter with proper type annotation + sig_params.append( + inspect.Parameter(param_name, inspect.Parameter.KEYWORD_ONLY, default=None, annotation=Any)) + + # Create the async function that will be wrapped by LongRunningFunctionTool + async def proxy_tool_func(**kwargs) -> Any: + # Access the original args and tool_context that were stored in run_async + original_args = getattr(self, "_current_args", kwargs) + original_tool_context = getattr(self, "_current_tool_context", None) + return await self._execute_proxy_tool(original_args, original_tool_context) + + # Set the function name, docstring, and signature to match the AG-UI tool + proxy_tool_func.__name__ = ag_ui_tool.name + proxy_tool_func.__doc__ = ag_ui_tool.description + + # Create new signature with extracted parameters + if sig_params: + proxy_tool_func.__signature__ = inspect.Signature(sig_params) + + # Initialize LongRunningFunctionTool with the proxy function + super().__init__(proxy_tool_func) + + def _get_declaration(self) -> Optional[types.FunctionDeclaration]: + """Create FunctionDeclaration from AG-UI tool parameters. + + We override this instead of delegating to the wrapped tool because + the TRPC's automatic function calling has difficulty parsing our + dynamically created function signature without proper type annotations. + """ + logger.debug("_get_declaration called for %s", self.name) + logger.debug("AG-UI tool parameters: %s", self.ag_ui_tool.parameters) + + # Convert AG-UI parameters (JSON Schema) to TRPC format + parameters = self.ag_ui_tool.parameters + + # Ensure it's a proper object schema + if not isinstance(parameters, dict): + parameters = {"type": "object", "properties": {}} + logger.warning("Tool %s had non-dict parameters, using empty schema", self.name) + + # Create FunctionDeclaration + function_declaration = types.FunctionDeclaration(name=self.name, + description=self.description, + parameters=types.Schema.model_validate(parameters)) + logger.debug("Created FunctionDeclaration for %s: %s", self.name, function_declaration) + return function_declaration + + async def _run_async_impl(self, *, args: dict[str, Any], tool_context: InvocationContext) -> Any: + """Execute the tool by storing args/context and calling parent implementation. + + Args: + args: The arguments for the tool call + tool_context: The TRPC tool context + + Returns: + None for long-running tools (client handles execution) + """ + # Store args and context for proxy function access + self._current_args = args + self._current_tool_context = tool_context + + # Call parent LongRunningFunctionTool implementation + return await super()._run_async_impl(args=args, tool_context=tool_context) + + async def _execute_proxy_tool(self, args: Dict[str, Any], tool_context: InvocationContext) -> Any: + """Execute the proxy tool logic and return None. + + Note: Tool call events (TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END) are NOT emitted here. + They are emitted by EventTranslator.translate_lro_function_calls() when processing + the LongRunningEvent that is generated by the parent LongRunningFunctionTool class. + This avoids duplicate event emission. + + Args: + args: Tool arguments from TRPC + tool_context: TRPC tool context + + Returns: + None for long-running tools (client handles the actual execution) + """ + logger.debug("Proxy tool execution: %s", self.ag_ui_tool.name) + logger.debug("Arguments received: %s", args) + logger.debug("Tool context type: %s", type(tool_context)) + + # Return None for long-running tools - client handles the actual execution + # The LongRunningEvent generated by parent class will be translated to + # AG-UI tool call events by EventTranslator.translate_lro_function_calls() + return None + + def __repr__(self) -> str: + """String representation of the proxy tool.""" + return f"ClientProxyTool(name='{self.name}', ag_ui_tool='{self.ag_ui_tool.name}')" diff --git a/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_toolset.py b/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_toolset.py new file mode 100644 index 000000000..f3adfeb93 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_client_proxy_toolset.py @@ -0,0 +1,89 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Dynamic toolset creation for client-side tools.""" + +import asyncio +from typing import List +from typing import Optional + +from ag_ui.core import Tool as AGUITool +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet + +from ._client_proxy_tool import ClientProxyTool + + +class ClientProxyToolset(BaseToolSet): + """Dynamic toolset that creates proxy tools from AG-UI tool definitions. + + This toolset is created for each run based on the tools provided in + the RunAgentInput, allowing dynamic tool availability per request. + """ + + def __init__(self, ag_ui_tools: List[AGUITool], event_queue: asyncio.Queue): + """Initialize the client proxy toolset. + + Args: + ag_ui_tools: List of AG-UI tool definitions + event_queue: Queue to emit AG-UI events + """ + super().__init__() + self.ag_ui_tools = ag_ui_tools + self.event_queue = event_queue + + logger.info("Initialized ClientProxyToolset with %s tools (all long-running)", len(ag_ui_tools)) + + async def get_tools(self, context: Optional[InvocationContext] = None) -> List[BaseTool]: + """Get all proxy tools for this toolset. + + Creates fresh ClientProxyTool instances for each AG-UI tool definition + with the current event queue reference. + + Args: + context: Optional context for tool filtering (unused currently) + + Returns: + List of ClientProxyTool instances + """ + # Create fresh proxy tools each time to avoid stale queue references + proxy_tools = [] + + for ag_ui_tool in self.ag_ui_tools: + try: + proxy_tool = ClientProxyTool(ag_ui_tool=ag_ui_tool, event_queue=self.event_queue) + proxy_tools.append(proxy_tool) + logger.debug("Created proxy tool for '%s' (long-running)", ag_ui_tool.name) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to create proxy tool for '%s': %s", ag_ui_tool.name, ex) + # Continue with other tools rather than failing completely + + return proxy_tools + + async def close(self) -> None: + """Clean up resources held by the toolset.""" + logger.info("Closing ClientProxyToolset") + + def __repr__(self) -> str: + """String representation of the toolset.""" + tool_names = [tool.name for tool in self.ag_ui_tools] + return f"ClientProxyToolset(tools={tool_names}, all_long_running=True)" diff --git a/trpc_agent_sdk/server/ag_ui/_core/_converters.py b/trpc_agent_sdk/server/ag_ui/_core/_converters.py new file mode 100644 index 000000000..989395bf0 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_converters.py @@ -0,0 +1,350 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Conversion utilities between AG-UI and TrpcAgent formats.""" + +import base64 +import binascii +import json +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from ag_ui.core import AssistantMessage +from ag_ui.core import BinaryInputContent +from ag_ui.core import FunctionCall +from ag_ui.core import InputContent +from ag_ui.core import Message +from ag_ui.core import SystemMessage +from ag_ui.core import TextInputContent +from ag_ui.core import ToolCall +from ag_ui.core import ToolMessage +from ag_ui.core import UserMessage +from trpc_agent_sdk import types +from trpc_agent_sdk.events import Event as TRPCEvent +from trpc_agent_sdk.log import logger + + +def _get_text_value(item: Union[dict, TextInputContent]) -> Optional[str]: + """Get text value from dict or TextInputContent.""" + if isinstance(item, TextInputContent): + return item.text + return item.get("text") + + +def _get_binary_attributes( + item: Union[dict, BinaryInputContent]) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]: + """Get binary attributes (data, mime_type, url, id) from dict or BinaryInputContent.""" + if isinstance(item, BinaryInputContent): + return ( + item.data, + item.mime_type, + item.url, + item.id, + ) + return (item.get("data"), item.get("mimeType") or item.get("mime_type"), item.get("url"), item.get("id")) + + +def _to_binary_part(data: Optional[str], mime_type: Optional[str], url: Optional[str], + binary_id: Optional[str]) -> Optional[types.Part]: + """Create a types.Part from binary data.""" + # currently, only data is supported + if not data: + logger.warning("BinaryInputContent: data is required; ignoring item without data.") + return None + + if url or binary_id: + logger.warning("BinaryInputContent: only data is supported; ignoring url/id fields.") + return None + + if not mime_type: + logger.warning("BinaryInputContent: missing mimeType; ignoring.") + return None + + try: + decoded = base64.b64decode(data, validate=True) + return types.Part(inline_data=types.Blob( + mime_type=mime_type, + data=decoded, + )) + except (binascii.Error, ValueError) as ex: + logger.warning("Failed to base64 decode BinaryInputContent.data: %s", ex) + return None + + +def _to_text_part(text: Optional[str]) -> Optional[types.Part]: + """Create a types.Part from text.""" + if not text: + return None + return types.Part(text=text) + + +def _is_text_content(item: Union[dict, InputContent]) -> bool: + is_text_dict = isinstance(item, dict) and item.get("type") == "text" + is_text_input_content = isinstance(item, TextInputContent) + return is_text_dict or is_text_input_content + + +def _is_binary_content(item: Union[dict, InputContent]) -> bool: + is_binary_dict = isinstance(item, dict) and item.get("type") == "binary" + is_binary_input_content = isinstance(item, BinaryInputContent) + return is_binary_dict or is_binary_input_content + + +def convert_trpc_event_to_ag_ui_message(event: TRPCEvent) -> Optional[Message]: + """Convert a TRPC event to an AG-UI message. + + Args: + event: TRPC event + + Returns: + AG-UI message or None if not convertible + """ + try: + # Skip events without content + if not event.content or not event.content.parts: + return None + + # Determine message type based on author/role + if event.author == "user": + # Extract text content + text_parts = [part.text for part in event.content.parts if part.text] + if text_parts: + return UserMessage(id=event.invocation_id, role="user", content="\n".join(text_parts)) + + else: # Assistant/model response + # Extract text and tool calls + text_parts = [] + tool_calls = [] + + for part in event.content.parts: + if part.text: + text_parts.append(part.text) + elif part.function_call: + tool_calls.append( + ToolCall( + id=getattr(part.function_call, "id", event.invocation_id), + type="function", + function=FunctionCall( + name=part.function_call.name, + arguments=(json.dumps(part.function_call.args) + if hasattr(part.function_call, "args") else "{}"), + ), + )) + + return AssistantMessage( + id=event.invocation_id, + role="assistant", + content="\n".join(text_parts) if text_parts else None, + tool_calls=tool_calls if tool_calls else None, + ) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error converting TRPC event %s: %s", event.invocation_id, ex) + + return None + + +def convert_state_to_json_patch(state_delta: Dict[str, Any]) -> List[Dict[str, Any]]: + """Convert a state delta to JSON Patch format (RFC 6902). + + Args: + state_delta: Dictionary of state changes + + Returns: + List of JSON Patch operations + """ + patches = [] + + for key, value in state_delta.items(): + # Determine operation type + if value is None: + # Remove operation + patches.append({"op": "remove", "path": f"/{key}"}) + else: + # Add/replace operation + # We use "replace" as it works for both existing and new keys + patches.append({"op": "replace", "path": f"/{key}", "value": value}) + + return patches + + +def convert_json_patch_to_state(patches: List[Dict[str, Any]]) -> Dict[str, Any]: + """Convert JSON Patch operations to a state delta dictionary. + + Args: + patches: List of JSON Patch operations + + Returns: + Dictionary of state changes + """ + state_delta = {} + + for patch in patches: + op = patch.get("op") + path = patch.get("path", "") + + # Extract key from path (remove leading slash) + key = path.lstrip("/") + + if op == "remove": + state_delta[key] = None + elif op in ["add", "replace"]: + state_delta[key] = patch.get("value") + # Ignore other operations for now (copy, move, test) + + return state_delta + + +def extract_text_from_content(content: types.Content) -> str: + """Extract all text from TRPC Content object. + + Args: + content: TRPC Content object + + Returns: + Combined text from all text parts + """ + if not content or not content.parts: + return "" + + text_parts = [] + for part in content.parts: + if part.text: + text_parts.append(part.text) + + return "\n".join(text_parts) + + +def create_error_message(error: Exception, context: str = "") -> str: + """Create a user-friendly error message. + + Args: + error: The exception + context: Additional context about where the error occurred + + Returns: + Formatted error message + """ + error_type = type(error).__name__ + error_msg = str(error) + + if context: + return f"{context}: {error_type} - {error_msg}" + else: + return f"{error_type}: {error_msg}" + + +def convert_message_content_to_parts(content: Optional[Union[str, List[Any]]]) -> List[types.Part]: + """Convert AG-UI message content into google.genai types.Part list. + + Supports: + - str -> [Part(text=...)] + - List[InputContent] -> text parts + binary parts (inline_data only; data/base64 only) + - List[dict] -> dict-shaped text/binary items (data/base64 only) + """ + if content is None: + return [] + + if isinstance(content, str): + return [types.Part(text=content)] if content else [] + + parts: List[types.Part] = [] + for item in content: + if _is_text_content(item): + text_value = _get_text_value(item) + part = _to_text_part(text_value) + if part: + parts.append(part) + elif _is_binary_content(item): + data, mime_type, url, binary_id = _get_binary_attributes(item) + part = _to_binary_part(data, mime_type, url, binary_id) + if part: + parts.append(part) + else: + item_type_name = item.get("type") if isinstance(item, dict) else type(item).__name__ + logger.debug("Ignoring unknown multi-model content item: %s", item_type_name) + return parts + + +def convert_ag_ui_messages_to_trpc(messages: List[Message]) -> List[TRPCEvent]: + """Convert AG-UI messages to TrpcAgent events. + + Args: + messages: List of AG-UI messages + + Returns: + List of TrpcAgent events + """ + + trpc_events = [] + + for message in messages: + try: + # Create base event + event = TRPCEvent(id=message.id, author=message.role, content=None) + + # Convert content based on message type + if isinstance(message, (UserMessage, SystemMessage)): + parts = convert_message_content_to_parts(message.content) + if parts: + event.content = types.Content(role=message.role, parts=parts) + + elif isinstance(message, AssistantMessage): + parts = [] + + # Add text content if present + if message.content: + parts.extend(convert_message_content_to_parts(message.content)) + + # Add tool calls if present + if message.tool_calls: + for tool_call in message.tool_calls: + parts.append( + types.Part(function_call=types.FunctionCall( + name=tool_call.function.name, + args=json.loads(tool_call.function.arguments) if isinstance( + tool_call.function.arguments, str) else tool_call.function.arguments, + id=tool_call.id))) + + if parts: + event.content = types.Content(role="model", parts=parts) + + elif isinstance(message, ToolMessage): + # Tool messages become function responses + event.content = types.Content( + role="function", + parts=[ + types.Part( + function_response=types.FunctionResponse(name=message.tool_call_id, + response={"result": message.content} if isinstance( + message.content, str) else message.content, + id=message.tool_call_id)) + ]) + + trpc_events.append(event) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error converting message %s: %s", message.id, ex) + continue + + return trpc_events diff --git a/trpc_agent_sdk/server/ag_ui/_core/_endpoint.py b/trpc_agent_sdk/server/ag_ui/_core/_endpoint.py new file mode 100644 index 000000000..0f8214849 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_endpoint.py @@ -0,0 +1,114 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""FastAPI endpoint for TRPC Agent middleware.""" + +from ag_ui.core import RunAgentInput +from ag_ui.encoder import EventEncoder +from fastapi import FastAPI +from fastapi import Request +from fastapi.responses import StreamingResponse +from trpc_agent_sdk.log import logger + +from ._agui_agent import AgUiAgent + + +def add_trpc_fastapi_endpoint(app: FastAPI, agent: AgUiAgent, path: str = "/"): + """Add TRPC Agent middleware endpoint to FastAPI app. + + Args: + app: FastAPI application instance + agent: Configured AgUiAgent instance + path: API endpoint path + """ + + @app.post(path) + async def trpc_endpoint(input_data: RunAgentInput, request: Request): # pylint: disable=unused-variable + """TRPC Agent middleware endpoint.""" + + # Get the accept header from the request + accept_header = request.headers.get("accept") + + # Create an event encoder to properly format SSE events + encoder = EventEncoder(accept=accept_header) + + async def event_generator(): + """Generate events from TRPC agent.""" + try: + async for event in agent.run(input_data, http_request=request): + try: + encoded = encoder.encode(event) + logger.debug("HTTP Response: %s", encoded) + yield encoded + except Exception as encoding_error: # pylint: disable=broad-except + # Handle encoding-specific errors + logger.error("❌ Event encoding error: %s", encoding_error, + exc_info=True) # Create a RunErrorEvent for encoding failures + from ag_ui.core import RunErrorEvent, EventType + + error_event = RunErrorEvent( + type=EventType.RUN_ERROR, + message=f"Event encoding failed: {str(encoding_error)}", + code="ENCODING_ERROR", + ) + try: + error_encoded = encoder.encode(error_event) + yield error_encoded + except Exception: # pylint: disable=broad-except + # If we can't even encode the error event, yield a basic SSE error + logger.error("Failed to encode error event, yielding basic SSE error") + yield 'event: error\ndata: {"error": "Event encoding failed"}\n\n' + break # Stop the stream after an encoding error + except Exception as agent_error: # pylint: disable=broad-except + # Handle errors from AgUiAgent.run() itself + logger.error( + "❌ AgUiAgent error: %s", agent_error, + exc_info=True) # AgUiAgent should have yielded a RunErrorEvent, but if something went wrong + # in the async generator itself, we need to handle it + try: + from ag_ui.core import RunErrorEvent, EventType + + error_event = RunErrorEvent( + type=EventType.RUN_ERROR, + message=f"Agent execution failed: {str(agent_error)}", + code="AGENT_ERROR", + ) + error_encoded = encoder.encode(error_event) + yield error_encoded + except Exception: # pylint: disable=broad-except + # If we can't encode the error event, yield a basic SSE error + logger.error("Failed to encode agent error event, yielding basic SSE error") + yield 'event: error\ndata: {"error": "Agent execution failed"}\n\n' + + return StreamingResponse(event_generator(), media_type=encoder.get_content_type()) + + +def create_trpc_app(agent: AgUiAgent, path: str = "/") -> FastAPI: + """Create a FastAPI app with TRPC Agent middleware endpoint. + + Args: + agent: Configured AgUiAgent instance + path: API endpoint path + + Returns: + FastAPI application instance + """ + app = FastAPI(title="TRPC Agent Middleware for AG-UI Protocol") + add_trpc_fastapi_endpoint(app, agent, path) + return app diff --git a/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py b/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py new file mode 100644 index 000000000..747fbab39 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py @@ -0,0 +1,737 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Event translator for converting TRPC agent events to AG-UI protocol events.""" + +import json +import uuid +from typing import Any +from typing import AsyncGenerator +from typing import Dict +from typing import List +from typing import Optional + +from ag_ui.core import BaseEvent +from ag_ui.core import CustomEvent +from ag_ui.core import EventType +from ag_ui.core import RunErrorEvent +from ag_ui.core import StateDeltaEvent +from ag_ui.core import StateSnapshotEvent +from ag_ui.core import TextMessageContentEvent +from ag_ui.core import TextMessageEndEvent +from ag_ui.core import TextMessageStartEvent +from ag_ui.core import ThinkingEndEvent +from ag_ui.core import ThinkingStartEvent +from ag_ui.core import ThinkingTextMessageContentEvent +from ag_ui.core import ThinkingTextMessageEndEvent +from ag_ui.core import ThinkingTextMessageStartEvent +from ag_ui.core import ToolCallArgsEvent +from ag_ui.core import ToolCallEndEvent +from ag_ui.core import ToolCallResultEvent +from ag_ui.core import ToolCallStartEvent +from trpc_agent_sdk import types +from trpc_agent_sdk.events import AgentCancelledEvent +from trpc_agent_sdk.events import Event as TRPCEvent +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import TOOL_STREAMING_ARGS + + +class EventTranslator: + """Translates TRPC agent events to AG-UI protocol events. + + This class handles the conversion between the two event systems, + managing streaming sequences and maintaining event consistency. + """ + + def __init__(self, long_running_tool_names: Optional[List[str]] = None): + """Initialize the event translator. + + Args: + long_running_tool_names: List of long-running tool names to track + """ + # Track tool call IDs for consistency + self._active_tool_calls: Dict[str, str] = {} # Tool call ID -> Tool call ID (for consistency) + # Track streaming message state + self._streaming_message_id: Optional[str] = None # Current streaming message ID + self._is_streaming: bool = False # Whether we're currently streaming a message + self._text_was_streamed: bool = False # Whether text was already delivered via streaming (survives force_close) + # Track thinking message state + self._is_thinking: bool = False # Whether we're currently streaming a thinking message + self._thinking_text: str = "" + self.long_running_tool_names: List[str] = long_running_tool_names or [] # Track the long running tool names + # Track streaming tool calls - tool_call_id -> last_args_length + self._streaming_tool_calls: Dict[str, int] = {} + # Track tool calls that were already emitted via streaming (to avoid duplicate emission) + self._streamed_tool_call_ids: set = set() + + async def translate(self, trpc_event: TRPCEvent, thread_id: str, run_id: str) -> AsyncGenerator[BaseEvent, None]: + """Translate a TRPC event to AG-UI protocol events. + + Args: + trpc_event: The TRPC event to translate + thread_id: The AG-UI thread ID + run_id: The AG-UI run ID + + Yields: + One or more AG-UI protocol events + """ + try: + # Handle AgentCancelledEvent from TRPC - silent termination + if isinstance(trpc_event, AgentCancelledEvent): + logger.info("Handling AgentCancelledEvent: %s", trpc_event.error_message) + # Force close any streaming message for consistency + async for close_event in self.force_close_streaming_message(): + yield close_event + # Don't yield any error event - silent termination + return + + # Check TRPC streaming state using proper methods + is_partial = getattr(trpc_event, "partial", False) + is_final_response = not is_partial + + logger.debug("📥 TRPC Event: partial=%s, is_final_response=%s", is_partial, is_final_response) + + # Skip user events (already in the conversation) + if trpc_event.author == "user": + logger.debug("Skipping user event") + return + + # Handle text content - extract thinking and text parts first + # IMPORTANT: Thinking parts ONLY appear at the beginning of the event sequence + # Once non-thinking content (text/function_calls) appears, thinking phase is complete + thinking_parts = [] + text_parts = [] + + if trpc_event.content and trpc_event.content.parts: + for part in trpc_event.content.parts: + if part.text: + if getattr(part, "thought", False): + thinking_parts.append(part.text) + else: + text_parts.append(part.text) + + # Check if this is a streaming tool call event + is_streaming_tool_call = trpc_event.is_streaming_tool_call() + + # Get function calls (skip for streaming tool calls as they are handled separately) + function_calls = [] if is_streaming_tool_call else trpc_event.get_function_calls() + + # Detect transition from thinking phase to content phase + has_non_thinking_content = bool(text_parts or function_calls) + + # Handle thinking content + if thinking_parts: + # Continue/start thinking stream + async for event in self._translate_thinking_content(trpc_event, thinking_parts): + yield event + + # If non-thinking content appears, finalize thinking + if has_non_thinking_content and self._is_thinking: + logger.info("📤 THINKING_TEXT_MESSAGE_CONTENT(Accumulated): %s", self._thinking_text) + # Finalize the ongoing thinking stream + timestamp_ms = int(trpc_event.timestamp * 1000) + end_msg_event = ThinkingTextMessageEndEvent(type=EventType.THINKING_TEXT_MESSAGE_END, + timestamp=timestamp_ms) + logger.info("📤 THINKING_TEXT_MESSAGE_END: %s", end_msg_event.model_dump_json()) + yield end_msg_event + + end_event = ThinkingEndEvent(type=EventType.THINKING_END, timestamp=timestamp_ms) + logger.info("📤 THINKING_END: %s", end_event.model_dump_json()) + yield end_event + self._is_thinking = False + self._thinking_text = "" + + # Handle regular text content + if text_parts: + async for event in self._translate_text_content(trpc_event, text_parts): + yield event + + # Handle streaming tool call events (partial tool call arguments) + if is_streaming_tool_call: + async for event in self._translate_streaming_tool_call(trpc_event): + yield event + + # call _translate_function_calls function to yield Tool Events (complete tool calls only) + if function_calls: + logger.debug("TRPC function calls detected: %s calls", len(function_calls)) + + # CRITICAL FIX: End any active text message stream before starting tool calls + # Per AG-UI protocol: TEXT_MESSAGE_END must be sent before TOOL_CALL_START + async for event in self.force_close_streaming_message(): + yield event + + # Close any streaming tool calls before emitting complete tool calls + async for event in self._close_streaming_tool_calls(trpc_event.timestamp): + yield event + + # NOW ACTUALLY YIELD THE EVENTS + async for event in self._translate_function_calls(function_calls, trpc_event.timestamp): + yield event + + # Handle function responses and yield the tool response event + # this is essential for scenarios when user has to render function response at frontend + function_responses = trpc_event.get_function_responses() + if function_responses: + # Function responses should be emitted to frontend so it can render the response as well + async for event in self._translate_function_response(function_responses, trpc_event.timestamp): + yield event + + # Handle state changes + if trpc_event.actions and trpc_event.actions.state_delta: + yield self._create_state_delta_event(trpc_event.actions.state_delta, trpc_event.timestamp) + + # Handle error events - distinguish recoverable tool errors from fatal system errors. + # Tool execution errors (with function_response) are recoverable: the error is already + # passed back to the LLM as a tool result, so the LLM can retry or adjust its approach. + # Only fatal errors (LLM failures, system errors) without function_response should + # emit RunErrorEvent to terminate the run. + if trpc_event.is_error() and not function_responses: + # Fatal system/LLM error - emit RunErrorEvent to terminate the run + logger.error("Fatal error (non-recoverable), error_code=%s, error_message=%s", trpc_event.error_code, + trpc_event.error_message) + # Force close any streaming message before emitting error + async for close_event in self.force_close_streaming_message(): + yield close_event + error_msg = (trpc_event.error_message or (trpc_event.custom_metadata or {}).get("error") + or "Unknown error") + yield RunErrorEvent( + type=EventType.RUN_ERROR, + message=error_msg, + code=trpc_event.error_code or "MODEL_ERROR", + ) + return + + # Handle custom events or metadata + if trpc_event.custom_metadata: + timestamp_ms = int(trpc_event.timestamp * 1000) + yield CustomEvent( + type=EventType.CUSTOM, + name="trpc_metadata", + value=trpc_event.custom_metadata, + timestamp=timestamp_ms, + ) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error translating TRPC event: %s", ex, exc_info=True) + # Don't yield error events here - let the caller handle errors + + async def _translate_text_content(self, trpc_event: TRPCEvent, + text_parts: List[str]) -> AsyncGenerator[BaseEvent, None]: + """Translate text content from TRPC event to AG-UI text message events. + + Args: + trpc_event: The TRPC event containing text content + text_parts: List of text parts (non-thinking) to translate + + Yields: + Text message events (START, CONTENT, END) + """ + if not text_parts: + return + + # Use proper TRPC streaming detection + is_partial = getattr(trpc_event, "partial", False) + is_final_response = not is_partial + + logger.debug("📥 Text event - partial=%s, is_final_response=%s, currently_streaming=%s", is_partial, + is_final_response, self._is_streaming) + + if is_final_response: + + # If a final text response wasn't streamed (not generated by an LLM) then deliver it in 3 events + if not self._is_streaming and not self._text_was_streamed: + logger.info("⏭️ Deliver non-llm response via message events event_id=%s", trpc_event.invocation_id) + + combined_text = "".join(text_parts) + timestamp_ms = int(trpc_event.timestamp * 1000) + message_events = [ + TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=trpc_event.invocation_id, + role="assistant", + timestamp=timestamp_ms, + ), + TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=trpc_event.invocation_id, + delta=combined_text, + timestamp=timestamp_ms, + ), + TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, + message_id=trpc_event.invocation_id, + timestamp=timestamp_ms), + ] + for msg in message_events: + yield msg + + logger.debug("⏭️ Skipping final response event (content already streamed)") + + # If we're currently streaming, this final response means we should end the stream + if self._is_streaming and self._streaming_message_id: + accumulated_text = "".join(text_parts) + logger.info("📤 TEXT_MESSAGE_CONTENT(Accumulated): %s", accumulated_text) + timestamp_ms = int(trpc_event.timestamp * 1000) + end_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, + message_id=self._streaming_message_id, + timestamp=timestamp_ms) + logger.info("📤 TEXT_MESSAGE_END (from final response): %s", end_event.model_dump_json()) + yield end_event + + # Reset streaming state + self._streaming_message_id = None + self._is_streaming = False + + self._text_was_streamed = False + return + + combined_text = "".join(text_parts) # Don't add newlines for streaming + + # Handle streaming logic + timestamp_ms = int(trpc_event.timestamp * 1000) + + if not self._is_streaming: + # Start of new message - emit START event + self._streaming_message_id = str(uuid.uuid4()) + self._is_streaming = True + self._text_was_streamed = True + + start_event = TextMessageStartEvent( + type=EventType.TEXT_MESSAGE_START, + message_id=self._streaming_message_id, + role="assistant", + timestamp=timestamp_ms, + ) + logger.info("📤 TEXT_MESSAGE_START: %s", start_event.model_dump_json()) + yield start_event + + # Always emit content (unless empty) + if combined_text: + content_event = TextMessageContentEvent( + type=EventType.TEXT_MESSAGE_CONTENT, + message_id=self._streaming_message_id, + delta=combined_text, + timestamp=timestamp_ms, + ) + logger.debug("📤 TEXT_MESSAGE_CONTENT: %s", content_event.model_dump_json()) + yield content_event + + # If turn is complete (final response), emit END event + if is_final_response: + end_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, + message_id=self._streaming_message_id, + timestamp=timestamp_ms) + logger.info("📤 TEXT_MESSAGE_END: %s", end_event.model_dump_json()) + yield end_event + + # Reset streaming state + self._streaming_message_id = None + self._is_streaming = False + logger.info("🏁 Streaming completed, state reset") + + async def translate_lro_function_calls(self, trpc_event: LongRunningEvent) -> AsyncGenerator[BaseEvent, None]: + """Translate long running function calls from TRPC event to AG-UI tool call events. + + Args: + trpc_event: The TRPC event containing function calls + + Yields: + Tool call events (START, ARGS, END) + """ + # Check if this is a LongRunningEvent + long_running_function_call = trpc_event.function_call + if long_running_function_call: + tool_call_id = long_running_function_call.id + timestamp_ms = int(trpc_event.timestamp * 1000) + + # Note: Long running tool names are tracked at initialization + # Individual tool call IDs are handled by the frontend + + # Emit TOOL_CALL_START + start_event = ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=long_running_function_call.name, + parent_message_id=None, + timestamp=timestamp_ms, + ) + logger.debug("📤 TOOL_CALL_START: tool=%s, id=%s", long_running_function_call.name, tool_call_id) + yield start_event + + # Emit TOOL_CALL_ARGS if we have arguments + if long_running_function_call.args: + # Convert args to string (JSON format) + args_str = (json.dumps(long_running_function_call.args, ensure_ascii=False) if isinstance( + long_running_function_call.args, dict) else str(long_running_function_call.args)) + logger.debug("📤 TOOL_CALL_ARGS: tool=%s, args=%s", long_running_function_call.name, args_str) + yield ToolCallArgsEvent(type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=args_str, + timestamp=timestamp_ms) + + # Emit TOOL_CALL_END + logger.debug("📤 TOOL_CALL_END: tool=%s, id=%s", long_running_function_call.name, tool_call_id) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=tool_call_id, timestamp=timestamp_ms) + + # Clean up tracking + self._active_tool_calls.pop(tool_call_id, None) + + async def _translate_streaming_tool_call(self, trpc_event: TRPCEvent) -> AsyncGenerator[BaseEvent, None]: + """Translate streaming tool call events to AG-UI tool call events. + + This method handles progressive streaming of tool call arguments. + It emits TOOL_CALL_START on first chunk, TOOL_CALL_ARGS for each chunk, + and tracks state for proper TOOL_CALL_END emission. + + Only delta mode is supported: uses TOOL_STREAMING_ARGS key, contains only new content. + + Args: + trpc_event: The TRPC event containing streaming tool call data + + Yields: + Tool call events (START, ARGS) - END is handled separately + """ + if not trpc_event.content or not trpc_event.content.parts: + return + + timestamp_ms = int(trpc_event.timestamp * 1000) + + for part in trpc_event.content.parts: + if not part.function_call: + continue + + func_call = part.function_call + tool_call_id = func_call.id or f"streaming_{uuid.uuid4().hex[:8]}" + tool_name = func_call.name or "unknown" + + # Skip long-running tools + if tool_name in self.long_running_tool_names: + continue + + # Get streaming args - only delta mode is supported + args = func_call.args or {} + + # Check for delta mode + streaming_delta = args.get(TOOL_STREAMING_ARGS) + if streaming_delta is None: + # Skip tool calls without delta updates + continue + + # Check if this is the first chunk for this tool call + if tool_call_id not in self._streaming_tool_calls: + # First chunk - emit TOOL_CALL_START + self._streaming_tool_calls[tool_call_id] = 0 + + # Close any active text message stream first + async for close_event in self.force_close_streaming_message(): + yield close_event + + start_event = ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=tool_name, + parent_message_id=None, + timestamp=timestamp_ms, + ) + logger.debug(f"📤 TOOL_CALL_START (streaming): tool={tool_name}, id={tool_call_id}") + yield start_event + + # Emit TOOL_CALL_ARGS with delta content + if streaming_delta: + # Track that this tool call is active (value is just a placeholder) + self._streaming_tool_calls[tool_call_id] = True + + args_event = ToolCallArgsEvent( + type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=streaming_delta, + timestamp=timestamp_ms, + ) + logger.debug(f"📤 TOOL_CALL_ARGS (streaming delta): tool={tool_name}, delta_len={len(streaming_delta)}") + yield args_event + + async def _close_streaming_tool_calls(self, timestamp: float) -> AsyncGenerator[BaseEvent, None]: + """Close any active streaming tool calls. + + This method emits TOOL_CALL_END for all streaming tool calls that were started + but not yet completed. + + Args: + timestamp: The timestamp for the events + + Yields: + TOOL_CALL_END events for each active streaming tool call + """ + timestamp_ms = int(timestamp * 1000) + + for tool_call_id in list(self._streaming_tool_calls.keys()): + end_event = ToolCallEndEvent( + type=EventType.TOOL_CALL_END, + tool_call_id=tool_call_id, + timestamp=timestamp_ms, + ) + logger.debug(f"📤 TOOL_CALL_END (streaming complete): id={tool_call_id}") + yield end_event + self._streamed_tool_call_ids.add(tool_call_id) + + # Clear streaming tool calls state + self._streaming_tool_calls.clear() + + async def _translate_function_calls( + self, + function_calls: list[types.FunctionCall], + timestamp: float, + ) -> AsyncGenerator[BaseEvent, None]: + """Translate function calls from TRPC event to AG-UI tool call events. + + Args: + function_calls: List of function calls from the event + timestamp: The timestamp from the TRPC event + + Yields: + Tool call events (START, ARGS, END) + """ + # Since we're not tracking streaming messages, use None for parent message + parent_message_id = None + timestamp_ms = int(timestamp * 1000) + + for func_call in function_calls: + tool_call_id = func_call.id + tool_name = getattr(func_call, "name", "unknown") + if tool_name in self.long_running_tool_names: + continue + + if tool_call_id in self._streamed_tool_call_ids: + self._streamed_tool_call_ids.discard(tool_call_id) + continue + + # Track the tool call + self._active_tool_calls[tool_call_id] = tool_call_id + + # Emit TOOL_CALL_START + start_event = ToolCallStartEvent( + type=EventType.TOOL_CALL_START, + tool_call_id=tool_call_id, + tool_call_name=func_call.name, + parent_message_id=parent_message_id, + timestamp=timestamp_ms, + ) + logger.debug("📤 TOOL_CALL_START: tool=%s, id=%s", func_call.name, tool_call_id) + yield start_event + + # Emit TOOL_CALL_ARGS if we have arguments + if hasattr(func_call, "args") and func_call.args: + # Convert args to string (JSON format) + args_str = (json.dumps(func_call.args, ensure_ascii=False) + if isinstance(func_call.args, dict) else str(func_call.args)) + logger.debug("📤 TOOL_CALL_ARGS: tool=%s, args=%s", func_call.name, args_str) + yield ToolCallArgsEvent(type=EventType.TOOL_CALL_ARGS, + tool_call_id=tool_call_id, + delta=args_str, + timestamp=timestamp_ms) + + # Emit TOOL_CALL_END + logger.debug("📤 TOOL_CALL_END: tool=%s, id=%s", func_call.name, tool_call_id) + yield ToolCallEndEvent(type=EventType.TOOL_CALL_END, tool_call_id=tool_call_id, timestamp=timestamp_ms) + + # Clean up tracking + self._active_tool_calls.pop(tool_call_id, None) + + async def _translate_function_response( + self, + function_response: list[types.FunctionResponse], + timestamp: float, + ) -> AsyncGenerator[BaseEvent, None]: + """Translate function calls from TRPC event to AG-UI tool call events. + + Args: + function_response: List of function response from the event + timestamp: The timestamp from the TRPC event + + Yields: + Tool result events (only for tools not in long_running_tool_names) + """ + timestamp_ms = int(timestamp * 1000) + + for func_response in function_response: + tool_call_id = func_response.id + tool_name = getattr(func_response, "name", "unknown") + # Only emit ToolCallResultEvent for tools which are not long_running_tool + # this is because long running tools are handled by the frontend + if tool_name not in self.long_running_tool_names: + response_content = json.dumps(func_response.response, ensure_ascii=False) + logger.info("📤 TOOL_CALL_RESULT: tool=%s, id=%s, response=%s", tool_name, tool_call_id, + response_content) + yield ToolCallResultEvent( + message_id=str(uuid.uuid4()), + type=EventType.TOOL_CALL_RESULT, + tool_call_id=tool_call_id, + content=response_content, + timestamp=timestamp_ms, + ) + else: + logger.debug("Skipping ToolCallResultEvent for long-running tool: %s (ID: %s)", tool_name, tool_call_id) + + def _create_state_delta_event(self, state_delta: Dict[str, Any], timestamp: float) -> StateDeltaEvent: + """Create a state delta event from TRPC state changes. + + Args: + state_delta: The state changes from TRPC + timestamp: The timestamp from the TRPC event + + Returns: + A StateDeltaEvent + """ + # Convert to JSON Patch format (RFC 6902) + # Use "add" operation which works for both new and existing paths + patches = [] + for key, value in state_delta.items(): + patches.append({"op": "add", "path": f"/{key}", "value": value}) + + timestamp_ms = int(timestamp * 1000) + return StateDeltaEvent(type=EventType.STATE_DELTA, delta=patches, timestamp=timestamp_ms) + + def _create_state_snapshot_event( + self, + state_snapshot: Dict[str, Any], + timestamp: float, + ) -> StateSnapshotEvent: + """Create a state snapshot event from TRPC state changes. + + Args: + state_snapshot: The state changes from TRPC + timestamp: The timestamp from the TRPC event + + Returns: + A StateSnapshotEvent + """ + timestamp_ms = int(timestamp * 1000) + return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state_snapshot, timestamp=timestamp_ms) + + async def force_close_streaming_message(self) -> AsyncGenerator[BaseEvent, None]: + """Force close any open streaming message. + + This should be called before ending a run to ensure proper message termination. + + Yields: + TEXT_MESSAGE_END event if there was an open streaming message + """ + if self._is_streaming and self._streaming_message_id: + logger.warning("🚨 Force-closing unterminated streaming message: %s", self._streaming_message_id) + + # Generate current timestamp since there's no TRPC event available + from datetime import datetime + + timestamp_ms = int(datetime.now().timestamp() * 1000) + + end_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, + message_id=self._streaming_message_id, + timestamp=timestamp_ms) + logger.debug("📤 TEXT_MESSAGE_END (forced): %s", end_event.model_dump_json()) + yield end_event + + # Reset streaming state + self._streaming_message_id = None + self._is_streaming = False + logger.debug("🔄 Streaming state reset after force-close") + + async def _translate_thinking_content(self, trpc_event: TRPCEvent, + thinking_parts: List[str]) -> AsyncGenerator[BaseEvent, None]: + """Translate thinking content from TRPC event to AG-UI thinking message events. + + This method handles ONLY the thinking phase, which appears at the beginning of event sequences. + Once non-thinking content appears, this method should no longer be called. + + Args: + trpc_event: The TRPC event containing thinking content + thinking_parts: List of thinking text parts + + Yields: + Thinking message events (START, CONTENT, END) + """ + if not thinking_parts: + return + + timestamp_ms = int(trpc_event.timestamp * 1000) + combined_text = "".join(thinking_parts) + is_partial = getattr(trpc_event, "partial", False) + + logger.debug("💭 Thinking event - partial=%s, currently_thinking=%s, text_len=%s", is_partial, self._is_thinking, + len(combined_text)) + + # Final response (not partial) - emit complete block + if not is_partial: + if self._is_thinking: + # Close the streaming thinking with final content + logger.info("📤 THINKING_TEXT_MESSAGE_CONTENT(Accumulated): %s", combined_text) + + end_msg_event = ThinkingTextMessageEndEvent(type=EventType.THINKING_TEXT_MESSAGE_END, + timestamp=timestamp_ms) + logger.info("📤 THINKING_TEXT_MESSAGE_END: %s", end_msg_event.model_dump_json()) + yield end_msg_event + + end_event = ThinkingEndEvent(type=EventType.THINKING_END, timestamp=timestamp_ms) + logger.info("📤 THINKING_END: %s", end_event.model_dump_json()) + yield end_event + self._thinking_text = "" + self._is_thinking = False + else: + # If thinking is not active, it means the thinking phase was already closed + # by the transition logic in translate() method (lines 122-133). + # Do NOT emit a new complete thinking block to avoid duplicates. + logger.debug("⏭️ Skipping final thinking event (already closed by transition logic)") + return + + # Streaming thinking (partial) + if not self._is_thinking: + # Start new thinking stream + self._is_thinking = True + start_event = ThinkingStartEvent(type=EventType.THINKING_START, timestamp=timestamp_ms) + logger.info("📤 THINKING_START: %s", start_event.model_dump_json()) + yield start_event + + start_msg_event = ThinkingTextMessageStartEvent(type=EventType.THINKING_TEXT_MESSAGE_START, + timestamp=timestamp_ms) + logger.info("📤 THINKING_TEXT_MESSAGE_START: %s", start_msg_event.model_dump_json()) + yield start_msg_event + + # Emit content delta + content_event = ThinkingTextMessageContentEvent(type=EventType.THINKING_TEXT_MESSAGE_CONTENT, + delta=combined_text, + timestamp=timestamp_ms) + logger.debug("📤 THINKING_TEXT_MESSAGE_CONTENT: %s", content_event.model_dump_json()) + self._thinking_text += combined_text + yield content_event + + def reset(self): + """Reset the translator state. + + This should be called between different conversation runs + to ensure clean state. + """ + self._active_tool_calls.clear() + self._streaming_message_id = None + self._is_streaming = False + self._text_was_streamed = False + self._is_thinking = False + self._thinking_text = "" + self._streaming_tool_calls.clear() + self._streamed_tool_call_ids.clear() + # Note: long_running_tool_names are not cleared as they are set at initialization + logger.debug("Reset EventTranslator state (including streaming and thinking state)") diff --git a/trpc_agent_sdk/server/ag_ui/_core/_execution_state.py b/trpc_agent_sdk/server/ag_ui/_core/_execution_state.py new file mode 100644 index 000000000..c66c25981 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_execution_state.py @@ -0,0 +1,134 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Execution state management for background TRPC runs with tool support.""" + +import asyncio +import time +from typing import Set + +from trpc_agent_sdk.log import logger + + +class ExecutionState: + """Manages the state of a background TRPC execution. + + This class tracks: + - The background asyncio task running the TRPC agent + - Event queue for streaming results to the client + - Execution timing and completion state + """ + + def __init__(self, task: asyncio.Task, thread_id: str, event_queue: asyncio.Queue): + """Initialize execution state. + + Args: + task: The asyncio task running the TRPC agent + thread_id: The thread ID for this execution + event_queue: Queue containing events to stream to client + """ + self.task = task + self.thread_id = thread_id + self.event_queue = event_queue + self.start_time = time.time() + self.is_complete = False + self.pending_tool_calls: Set[str] = set() # Track outstanding tool call IDs for HITL + + logger.debug("Created execution state for thread %s", thread_id) + + def is_stale(self, timeout_seconds: int) -> bool: + """Check if this execution has been running too long. + + Args: + timeout_seconds: Maximum execution time in seconds + + Returns: + True if execution has exceeded timeout + """ + return time.time() - self.start_time > timeout_seconds + + async def cancel(self): + """Cancel the execution and clean up resources.""" + logger.info("Cancelling execution for thread %s", self.thread_id) + + # Cancel the background task + if not self.task.done(): + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + + self.is_complete = True + + def get_execution_time(self) -> float: + """Get the total execution time in seconds. + + Returns: + Time in seconds since execution started + """ + return time.time() - self.start_time + + def add_pending_tool_call(self, tool_call_id: str): + """Add a tool call ID to the pending set. + + Args: + tool_call_id: The tool call ID to track + """ + self.pending_tool_calls.add(tool_call_id) + logger.debug("Added pending tool call %s to thread %s", tool_call_id, self.thread_id) + + def remove_pending_tool_call(self, tool_call_id: str): + """Remove a tool call ID from the pending set. + + Args: + tool_call_id: The tool call ID to remove + """ + self.pending_tool_calls.discard(tool_call_id) + logger.debug("Removed pending tool call %s from thread %s", tool_call_id, self.thread_id) + + def has_pending_tool_calls(self) -> bool: + """Check if there are outstanding tool calls waiting for responses. + + Returns: + True if there are pending tool calls (HITL scenario) + """ + return len(self.pending_tool_calls) > 0 + + def get_status(self) -> str: + """Get a human-readable status of the execution. + + Returns: + Status string describing the current state + """ + if self.is_complete: + if self.has_pending_tool_calls(): + return "complete_awaiting_tools" + else: + return "complete" + elif self.task.done(): + return "task_done" + else: + return "running" + + def __repr__(self) -> str: + """String representation of the execution state.""" + return (f"ExecutionState(thread_id='{self.thread_id}', " + f"status='{self.get_status()}', " + f"runtime={self.get_execution_time():.1f}s)") diff --git a/trpc_agent_sdk/server/ag_ui/_core/_feed_back_content.py b/trpc_agent_sdk/server/ag_ui/_core/_feed_back_content.py new file mode 100644 index 000000000..212110c0e --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_feed_back_content.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 Apache-2.0. +"""Feedback content structure for user feedback handler.""" + +from __future__ import annotations + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import PrivateAttr +from trpc_agent_sdk.sessions import Session + + +class AgUiUserFeedBack(BaseModel): + """Content provided to user feedback handler. + + This structure contains the session and tool result information + that the user can inspect and modify in their feedback handler. + + Example: + def my_feedback_handler(content: AgUiUserFeedBack): + # Modify session state + content.session.state['key'] = 'value' + # Mark as modified so changes are saved + content.mark_session_modified() + + # Optionally modify the tool message that will be sent to the agent + content.tool_message = "modified content" + + Attributes: + session: The TRPC Session object (can be modified) + tool_name: Name of the tool that was called (read-only) + tool_message: The tool result message/content from the user (can be modified) + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True) + + session: Session = Field(description="The TRPC Session object") + tool_name: str = Field(description="Name of the tool that was called", allow_mutation=False) + tool_message: str = Field(description="The tool result message/content from the user") + + _session_modified: bool = PrivateAttr(default=False) + + def mark_session_modified(self) -> None: + """Mark the session as modified. + + Call this method after modifying the session to indicate that + changes need to be saved. + """ + self._session_modified = True + + def check_session_modified(self) -> bool: + """Check if the session has been modified. + + Returns: + True if session has been modified, False otherwise + """ + return self._session_modified diff --git a/trpc_agent_sdk/server/ag_ui/_core/_http_req.py b/trpc_agent_sdk/server/ag_ui/_core/_http_req.py new file mode 100644 index 000000000..b241ec916 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_http_req.py @@ -0,0 +1,28 @@ +# 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. +"""Utilities for AG-UI HTTP request access in InvocationContext.""" + +from starlette.requests import Request +from trpc_agent_sdk.configs import RunConfig +from trpc_agent_sdk.context import InvocationContext + +_AGUI_HTTP_REQ_KEY: str = "_trpc_ag_ui_http_req" + + +def set_agui_http_req(run_config: RunConfig, request: Request) -> None: + """Inject AG-UI HTTP request into run_config for current invocation.""" + run_config.agent_run_config[_AGUI_HTTP_REQ_KEY] = request + + +def get_agui_http_req(ctx: InvocationContext) -> Request | None: + """Get AG-UI HTTP request from invocation context run_config.""" + if ctx.run_config is None: + return None + + request = ctx.run_config.agent_run_config.get(_AGUI_HTTP_REQ_KEY) + if not isinstance(request, Request): + return None + return request diff --git a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py new file mode 100644 index 000000000..bec009411 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py @@ -0,0 +1,612 @@ +# 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. +# +# Below code are copy and modified from https://github.com/ag-ui-protocol/ag-ui.git +# +# MIT License +# +# Copyright (c) 2025 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +"""Session manager that adds production features to TRPC's native session service.""" + +import asyncio +import time +from typing import Any +from typing import Dict +from typing import Optional +from typing import Set +from typing import Union + +from trpc_agent_sdk.log import logger + + +class SessionManager: + """Session manager that wraps TRPC's session service. + + Adds essential production features: + - Timeout monitoring based on TRPC's lastUpdateTime + - Cross-user/app session enumeration + - Per-user session limits + - Automatic cleanup of expired sessions + - Optional automatic session memory on deletion + - State management and updates + """ + + _instance = None + _initialized = False + + def __new__(cls, session_service=None, **kwargs): + """Ensure singleton instance.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__( + self, + session_service=None, + memory_service=None, + session_timeout_seconds: int = 1200, # 20 minutes default + cleanup_interval_seconds: int = 300, # 5 minutes + max_sessions_per_user: Optional[int] = None, + auto_cleanup: bool = True, + ): + """Initialize the session manager. + + Args: + session_service: TRPC session service (required on first initialization) + memory_service: Optional TRPC memory service for automatic session memory + session_timeout_seconds: Time before a session is considered expired + cleanup_interval_seconds: Interval between cleanup cycles + max_sessions_per_user: Maximum concurrent sessions per user (None = unlimited) + auto_cleanup: Enable automatic session cleanup task + """ + if self._initialized: + return + + if session_service is None: + from trpc_agent_sdk.sessions import InMemorySessionService + + session_service = InMemorySessionService() + + self._session_service = session_service + self._memory_service = memory_service + self._timeout = session_timeout_seconds + self._cleanup_interval = cleanup_interval_seconds + self._max_per_user = max_sessions_per_user + self._auto_cleanup = auto_cleanup + + # Minimal tracking: just keys and user counts + self._session_keys: Set[str] = set() # "app_name:session_id" keys + self._user_sessions: Dict[str, Set[str]] = {} # user_id -> set of session_keys + + self._cleanup_task: Optional[asyncio.Task] = None + self._initialized = True + + logger.info("Initialized SessionManager - timeout: %ss, cleanup: %ss, max/user: %s, memory: %s", + session_timeout_seconds, cleanup_interval_seconds, max_sessions_per_user or 'unlimited', + 'enabled' if memory_service else 'disabled') + + @classmethod + def get_instance(cls, **kwargs): + """Get the singleton instance.""" + return cls(**kwargs) + + @classmethod + def reset_instance(cls): + """Reset singleton for testing.""" + if cls._instance and hasattr(cls._instance, "_cleanup_task"): + task = cls._instance._cleanup_task + if task: + try: + task.cancel() + except RuntimeError: + pass + cls._instance = None + cls._initialized = False + + async def get_or_create_session(self, + session_id: str, + app_name: str, + user_id: str, + initial_state: Optional[Dict[str, Any]] = None) -> Any: + """Get existing session or create new one. + + Returns the TRPC session object directly. + """ + session_key = f"{app_name}:{session_id}" + + # Check user limits before creating + if session_key not in self._session_keys and self._max_per_user: + user_count = len(self._user_sessions.get(user_id, set())) + if user_count >= self._max_per_user: + # Remove oldest session for this user + await self._remove_oldest_user_session(user_id) + + # Get or create via TRPC + session = await self._session_service.get_session(session_id=session_id, app_name=app_name, user_id=user_id) + + if not session: + session = await self._session_service.create_session(session_id=session_id, + user_id=user_id, + app_name=app_name, + state=initial_state or {}) + logger.info("Created new session: %s", session_key) + else: + logger.debug("Retrieved existing session: %s", session_key) + + # Track the session key + self._track_session(session_key, user_id) + + # Start cleanup if needed + if self._auto_cleanup and not self._cleanup_task: + self._start_cleanup_task() + + return session + + # ===== STATE MANAGEMENT METHODS ===== + + async def update_session_state(self, + session_id: str, + app_name: str, + user_id: str, + state_updates: Dict[str, Any], + merge: bool = True) -> bool: + """Update session state with new values. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + state_updates: Dictionary of state key-value pairs to update + merge: If True, merge with existing state; if False, replace completely + + Returns: + True if successful, False otherwise + """ + try: + session = await self._session_service.get_session(session_id=session_id, app_name=app_name, user_id=user_id) + + if not session: + logger.debug( + "Session not found for update: %s:%s - this may be normal if session is still being created", + app_name, session_id) + return False + + if not state_updates: + logger.debug("No state updates provided for session: %s:%s", app_name, session_id) + return False + + # Apply state updates using EventActions + from trpc_agent_sdk.events import Event + from trpc_agent_sdk.types import EventActions + + # Prepare state delta + if merge: + # Merge with existing state + state_delta = state_updates + else: + # Replace entire state + state_delta = state_updates + # Note: Complete replacement might need clearing existing keys + # This depends on TRPC's behavior - may need to explicitly clear + + # Create event with state changes + actions = EventActions(state_delta=state_delta) + event = Event( + invocation_id=f"state_update_{int(time.time())}", + author="system", + actions=actions, + timestamp=time.time(), + partial=True, + ) + + # Apply changes through TRPC's event system + await self._session_service.append_event(session, event) + + logger.debug("Updated state for session %s:%s", app_name, session_id) + logger.debug("State updates: %s", state_updates) + + return True + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to update session state: %s", ex, exc_info=True) + return False + + async def get_session_state(self, session_id: str, app_name: str, user_id: str) -> Optional[Dict[str, Any]]: + """Get current session state. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + + Returns: + Session state dictionary or None if session not found + """ + try: + session = await self._session_service.get_session(session_id=session_id, app_name=app_name, user_id=user_id) + + if not session: + logger.debug("Session not found when getting state: %s:%s", app_name, session_id) + return None + + # Return state as dictionary + if hasattr(session.state, "to_dict"): + return session.state.to_dict() + else: + # Fallback for dict-like state objects + return dict(session.state) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to get session state: %s", ex, exc_info=True) + return None + + async def get_state_value(self, session_id: str, app_name: str, user_id: str, key: str, default: Any = None) -> Any: + """Get a specific value from session state. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + key: State key to retrieve + default: Default value if key not found + + Returns: + Value for the key or default + """ + try: + session = await self._session_service.get_session(session_id=session_id, app_name=app_name, user_id=user_id) + + if not session: + logger.debug("Session not found when getting state value: %s:%s", app_name, session_id) + return default + + if hasattr(session.state, "get"): + return session.state.get(key, default) + else: + return session.state.get(key, default) if key in session.state else default + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to get state value: %s", ex, exc_info=True) + return default + + async def set_state_value(self, session_id: str, app_name: str, user_id: str, key: str, value: Any) -> bool: + """Set a specific value in session state. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + key: State key to set + value: Value to set + + Returns: + True if successful, False otherwise + """ + return await self.update_session_state(session_id=session_id, + app_name=app_name, + user_id=user_id, + state_updates={key: value}) + + async def remove_state_keys(self, session_id: str, app_name: str, user_id: str, keys: Union[str, list]) -> bool: + """Remove specific keys from session state. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + keys: Single key or list of keys to remove + + Returns: + True if successful, False otherwise + """ + try: + if isinstance(keys, str): + keys = [keys] + + # Get current state + current_state = await self.get_session_state(session_id, app_name, user_id) + if not current_state: + return False + + # Create state delta to remove keys (set to None for removal) + state_delta = {key: None for key in keys if key in current_state} + + if not state_delta: + logger.debug("No keys to remove from session %s:%s", app_name, session_id) + return True + + return await self.update_session_state(session_id=session_id, + app_name=app_name, + user_id=user_id, + state_updates=state_delta) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to remove state keys: %s", ex, exc_info=True) + return False + + async def clear_session_state(self, + session_id: str, + app_name: str, + user_id: str, + preserve_prefixes: Optional[list] = None) -> bool: + """Clear session state, optionally preserving certain prefixes. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + preserve_prefixes: List of prefixes to preserve (e.g., ['user:', 'app:']) + + Returns: + True if successful, False otherwise + """ + try: + current_state = await self.get_session_state(session_id, app_name, user_id) + if not current_state: + return False + + preserve_prefixes = preserve_prefixes or [] + + # Determine which keys to remove + keys_to_remove = [] + for key in current_state.keys(): + should_preserve = any(key.startswith(prefix) for prefix in preserve_prefixes) + if not should_preserve: + keys_to_remove.append(key) + + if keys_to_remove: + return await self.remove_state_keys(session_id=session_id, + app_name=app_name, + user_id=user_id, + keys=keys_to_remove) + + return True + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to clear session state: %s", ex, exc_info=True) + return False + + async def initialize_session_state( + self, + session_id: str, + app_name: str, + user_id: str, + initial_state: Dict[str, Any], + overwrite_existing: bool = False, + ) -> bool: + """Initialize session state with default values. + + Args: + session_id: Session identifier + app_name: Application name + user_id: User identifier + initial_state: Initial state values + overwrite_existing: Whether to overwrite existing values + + Returns: + True if successful, False otherwise + """ + try: + if not overwrite_existing: + # Only set values that don't already exist + current_state = await self.get_session_state(session_id, app_name, user_id) + if current_state: + # Filter out keys that already exist + filtered_state = {key: value for key, value in initial_state.items() if key not in current_state} + if not filtered_state: + logger.debug("No new state values to initialize for session %s:%s", app_name, session_id) + return True + initial_state = filtered_state + + return await self.update_session_state(session_id=session_id, + app_name=app_name, + user_id=user_id, + state_updates=initial_state) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to initialize session state: %s", ex, exc_info=True) + return False + + # ===== BULK STATE OPERATIONS ===== + + async def bulk_update_user_state(self, + user_id: str, + state_updates: Dict[str, Any], + app_name_filter: Optional[str] = None) -> Dict[str, bool]: + """Update state across all sessions for a user. + + Args: + user_id: User identifier + state_updates: State updates to apply + app_name_filter: Optional filter for specific app + + Returns: + Dictionary mapping session_key to success status + """ + results = {} + + if user_id not in self._user_sessions: + logger.debug("No sessions found for user %s", user_id) + return results + + for session_key in self._user_sessions[user_id]: + app_name, session_id = session_key.split(":", 1) + + # Apply filter if specified + if app_name_filter and app_name != app_name_filter: + continue + + success = await self.update_session_state(session_id=session_id, + app_name=app_name, + user_id=user_id, + state_updates=state_updates) + + results[session_key] = success + + return results + + # ===== EXISTING METHODS (unchanged) ===== + + def _track_session(self, session_key: str, user_id: str): + """Track a session key for enumeration.""" + self._session_keys.add(session_key) + + if user_id not in self._user_sessions: + self._user_sessions[user_id] = set() + self._user_sessions[user_id].add(session_key) + + def _untrack_session(self, session_key: str, user_id: str): + """Remove session tracking.""" + self._session_keys.discard(session_key) + + if user_id in self._user_sessions: + self._user_sessions[user_id].discard(session_key) + if not self._user_sessions[user_id]: + del self._user_sessions[user_id] + + async def _remove_oldest_user_session(self, user_id: str): + """Remove the oldest session for a user based on lastUpdateTime.""" + if user_id not in self._user_sessions: + return + + oldest_session = None + oldest_time = float("inf") + + # Find oldest session by checking TRPC's lastUpdateTime + for session_key in self._user_sessions[user_id]: + app_name, session_id = session_key.split(":", 1) + try: + session = await self._session_service.get_session(session_id=session_id, + app_name=app_name, + user_id=user_id) + if session and hasattr(session, "last_update_time"): + update_time = session.last_update_time + if update_time < oldest_time: + oldest_time = update_time + oldest_session = session + except Exception as ex: # pylint: disable=broad-except + logger.error("Error checking session %s: %s", session_key, ex) + + if oldest_session: + session_key = f"{oldest_session.app_name}:{oldest_session.id}" + await self._delete_session(oldest_session) + logger.info("Removed oldest session for user %s: %s", user_id, session_key) + + async def _delete_session(self, session): + """Delete a session using the session object directly. + + Args: + session: The TRPC session object to delete + """ + if not session: + logger.warning("Cannot delete None session") + return + + session_key = f"{session.app_name}:{session.id}" + + try: + await self._session_service.delete_session(session_id=session.id, + app_name=session.app_name, + user_id=session.user_id) + logger.debug("Deleted session: %s", session_key) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to delete session %s: %s", session_key, ex) + + self._untrack_session(session_key, session.user_id) + + def _start_cleanup_task(self): + """Start the cleanup task if not already running.""" + try: + loop = asyncio.get_running_loop() + self._cleanup_task = loop.create_task(self._cleanup_loop()) + logger.debug("Started session cleanup task %s for SessionManager %s", id(self._cleanup_task), id(self)) + except RuntimeError: + logger.debug("No event loop, cleanup will start later") + + async def _cleanup_loop(self): + """Periodically clean up expired sessions.""" + logger.debug("Cleanup loop started for SessionManager %s", id(self)) + while True: + try: + await asyncio.sleep(self._cleanup_interval) + logger.debug("Running cleanup on SessionManager %s", id(self)) + await self._cleanup_expired_sessions() + except asyncio.CancelledError: + logger.info("Cleanup task cancelled") + break + except Exception as ex: # pylint: disable=broad-except + logger.error("Cleanup error: %s", ex, exc_info=True) + + async def _cleanup_expired_sessions(self): + """Find and remove expired sessions based on lastUpdateTime.""" + current_time = time.time() + expired_count = 0 + + # Check all tracked sessions + for session_key in list(self._session_keys): # Copy to avoid modification during iteration + app_name, session_id = session_key.split(":", 1) + + # Find user_id for this session + user_id = None + for uid, keys in self._user_sessions.items(): + if session_key in keys: + user_id = uid + break + + if not user_id: + continue + + try: + session = await self._session_service.get_session(session_id=session_id, + app_name=app_name, + user_id=user_id) + + if session and hasattr(session, "last_update_time"): + age = current_time - session.last_update_time + if age > self._timeout: + # Check for pending tool calls before deletion (HITL scenarios) + pending_calls = session.state.get("pending_tool_calls", []) if session.state else [] + if pending_calls: + logger.info("Preserving expired session %s - has %s pending tool calls (HITL)", session_key, + len(pending_calls)) + else: + await self._delete_session(session) + expired_count += 1 + elif not session: + # Session doesn't exist, just untrack it + self._untrack_session(session_key, user_id) + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error checking session %s: %s", session_key, ex) + + if expired_count > 0: + logger.info("Cleaned up %s expired sessions", expired_count) + + def get_session_count(self) -> int: + """Get total number of tracked sessions.""" + return len(self._session_keys) + + def get_user_session_count(self, user_id: str) -> int: + """Get number of sessions for a user.""" + return len(self._user_sessions.get(user_id, set())) + + async def stop_cleanup_task(self): + """Stop the cleanup task.""" + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/__init__.py b/trpc_agent_sdk/server/ag_ui/_plugin/__init__.py new file mode 100644 index 000000000..c77b724b1 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/__init__.py @@ -0,0 +1,24 @@ +# 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. +"""AG-UI plugin for tRPC-Python framework.""" + +from ._langgraph_event_translator import AgUiLangGraphEventTranslator +from ._langgraph_event_translator import AgUiTranslationContext +from ._manager import AgUiManager +from ._registry import AgUiServiceRegistry +from ._registry import get_agui_service_registry +from ._service import AgUiService +from ._utils import event_generator + +__all__ = [ + "AgUiLangGraphEventTranslator", + "AgUiTranslationContext", + "AgUiManager", + "AgUiServiceRegistry", + "get_agui_service_registry", + "AgUiService", + "event_generator", +] diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/_langgraph_event_translator.py b/trpc_agent_sdk/server/ag_ui/_plugin/_langgraph_event_translator.py new file mode 100644 index 000000000..64b2eb631 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/_langgraph_event_translator.py @@ -0,0 +1,65 @@ +# 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. +"""AG-UI event translator for LangGraph events (inheritable).""" + +from abc import ABC +from dataclasses import dataclass + +from trpc_agent_sdk.agents.utils import TRPC_EVENT_MARKER +from trpc_agent_sdk.events import Event as TrpcEvent +from trpc_agent_sdk.events import EventTranslatorBase + + +@dataclass +class AgUiTranslationContext: + """Context for AG-UI event translation. + + Attributes: + thread_id: The AG-UI thread ID + run_id: The AG-UI run ID + """ + thread_id: str + run_id: str + + +class AgUiLangGraphEventTranslator(EventTranslatorBase, ABC): + """Base translator for LangGraph trpc Events to AG-UI events. + + This class provides the need_translate() implementation to identify + LangGraph events (those with TRPC_EVENT_MARKER in custom_metadata). + + Users must inherit from this class and implement the translate() method + to define how to convert events to AG-UI protocol events. + + Example: + class MyAgUiTranslator(AgUiLangGraphEventTranslator): + async def translate(self, event, context): + # Your translation logic here + event_type = event.custom_metadata.get(LANGGRAPH_EVENT_TYPE) + if event_type == "text": + # Handle text events + yield create_text_message_event(event, context) + elif event_type == "custom": + # Handle custom events + custom_data = event.custom_metadata.get("data", {}) + yield CustomEvent( + type=EventType.CUSTOM, + name="my_custom", + value=custom_data, + timestamp=int(event.timestamp * 1000), + ) + """ + + def need_translate(self, event: TrpcEvent) -> bool: + """Check if this event was created by LangGraphEventWriter. + + Args: + event: The trpc Event to check + + Returns: + True if this event has TRPC_EVENT_MARKER in custom_metadata + """ + return (event.custom_metadata is not None and TRPC_EVENT_MARKER in event.custom_metadata) diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/_manager.py b/trpc_agent_sdk/server/ag_ui/_plugin/_manager.py new file mode 100644 index 000000000..cd922e01b --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/_manager.py @@ -0,0 +1,87 @@ +# 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. +"""AG-UI manager: manages the AG-UI agents and services.""" + +from typing import Any +from typing import Dict +from typing import Optional + +import uvicorn +from fastapi import FastAPI + +from .._core import AgUiAgent +from ._registry import get_agui_service_registry +from ._service import AgUiService + + +class AgUiManager: + """AG-UI manager: manages the AG-UI agents and services.""" + + def __init__(self, app: FastAPI = None): + self._agui_service_registry = get_agui_service_registry() + self._agui_agents: Dict[str, AgUiAgent] = {} + self._app = app + + def set_app(self, app: FastAPI) -> None: + """Set the FastAPI app. + + Args: + app: The FastAPI app. + """ + self._app = app + + def register_service(self, service_name: str, service: AgUiService) -> None: + """Register an AG-UI service. + + Args: + service_name: The name of the service. + service: The AG-UI service to register. + """ + self._agui_service_registry.register_service(service_name, service) + + def get_service(self, service_name: str) -> Optional[AgUiService]: + """Get an AG-UI service. + + Args: + service_name: The name of the service. + + Returns: + The AG-UI service. + """ + return self._agui_service_registry.get_service(service_name) + + def get_agents(self) -> Dict[str, AgUiAgent]: + """Get the AG-UI agents. + + Returns: + A dictionary mapping URI paths to AgUiAgent instances. + """ + return self._agui_agents + + def _build_agents(self) -> None: + """Build the AG-UI agents.""" + agui_services: Dict[str, AgUiService] = self._agui_service_registry.get_service() + for service in agui_services.values(): + service.create_agents() + self._agui_agents.update(service.agents) + if service.app is None: + service.set_fastapi(self._app) + + def run(self, host: str, port: int, **kwargs: Any) -> None: + """Run the AG-UI manager. + + Args: + host: The host to run the AG-UI manager on. + port: The port to run the AG-UI manager on. + kwargs: Additional keyword arguments to pass to the uvicorn server. + """ + self._build_agents() + uvicorn.run(self._app, host=host, port=port, **kwargs) + + async def close(self) -> None: + """Close the AG-UI manager.""" + for agent in self._agui_agents.values(): + await agent.close() diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/_registry.py b/trpc_agent_sdk/server/ag_ui/_plugin/_registry.py new file mode 100644 index 000000000..f125e231b --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/_registry.py @@ -0,0 +1,42 @@ +# 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. +"""Service registry for AG-UI services.""" + +from typing import Dict +from typing import Optional + +from trpc_agent_sdk.utils import SingletonBase + +from ._service import AgUiService + + +class AgUiServiceRegistry(SingletonBase): + """Registry for AG-UI services.""" + + def __init__(self): + super().__init__() + self._services: Dict[str, AgUiService] = {} + + def register_service(self, service_name: str, service: AgUiService) -> None: + """Register an AG-UI service.""" + self._services[service_name] = service + + def get_service(self, service_name: Optional[str] = None) -> Optional[AgUiService]: + """Get a registered service.""" + if service_name is None: + return self._services + return self._services.get(service_name) + + +_agui_service_registry: Optional[AgUiServiceRegistry] = None + + +def get_agui_service_registry() -> AgUiServiceRegistry: + """Get the singleton instance of the AG-UI service registry.""" + global _agui_service_registry + if _agui_service_registry is None: + _agui_service_registry = AgUiServiceRegistry() + return _agui_service_registry diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/_service.py b/trpc_agent_sdk/server/ag_ui/_plugin/_service.py new file mode 100644 index 000000000..5cb5c133f --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/_service.py @@ -0,0 +1,149 @@ +# 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. +"""Service for managing AG-UI agents.""" + +from typing import Callable +from typing import Dict +from typing import Optional +from typing import Union + +from ag_ui.core import RunAgentInput +from ag_ui.encoder import EventEncoder +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi import Request +from fastapi.responses import StreamingResponse +from trpc_agent_sdk.log import logger + +from .._core import AgUiAgent +from ._utils import event_generator + + +class AgUiService: + """Service for managing AG-UI agents. + + This service provides functionality to register and manage multiple AG-UI agents + with their associated URI endpoints. It handles agent registration and FastAPI + route configuration. + """ + + def __init__(self, service_name: str, app: Optional[FastAPI] = None, agents: Dict[str, AgUiAgent] = None): + """Initialize the AgUiService. + + Args: + service_name: Name of the service used for route registration + app: Optional FastAPI app instance. If not provided, the service + will not be registered with the FastAPI app. + agents: Optional dictionary of agents keyed by URI path. If not provided, + an empty dictionary will be used. + """ + self._service_name = service_name + self._app = app + self._agents: Dict[str, AgUiAgent] = agents or {} + self._agui_agent_factories: Dict[str, Callable[[], AgUiAgent]] = {} + + @property + def app(self) -> FastAPI: + """Get the FastAPI app for the service. + + Returns: + The FastAPI app instance. + """ + return self._app + + @property + def service_name(self) -> str: + """Get the name of the service. + + Returns: + The service name string. + """ + return self._service_name + + @property + def agents(self) -> Dict[str, AgUiAgent]: + """Get the agents of the service. + + Returns: + A dictionary mapping URI paths to AgUiAgent instances. + """ + return self._agents + + def create_agents(self) -> None: + """Create AgUiAgent instances. + + This is a base implementation that returns an empty dictionary. + Subclasses should override this method to provide actual agent creation logic. + + Returns: + A dictionary mapping URI paths to AgUiAgent instances. The base + implementation returns an empty dictionary. + """ + if not self._agui_agent_factories: + return None + for uri, factory in self._agui_agent_factories.items(): + self._agents[uri] = factory() + + def add_agent(self, uri: str, agui_agent: Union[AgUiAgent, Callable[[], AgUiAgent]]) -> None: + """Add an AgUiAgent with a specific URI. + + This method registers an agent instance directly with the service and sets up + the FastAPI route for handling requests. Note that this method is primarily + for backward compatibility with older interfaces and is not recommended for + use, especially in multi-process environments where agents may have cross-process + risks. + + Args: + uri: The URI path for the agent endpoint + agui_agent: The AgUiAgent instance to register + """ + if isinstance(agui_agent, Callable): + self._agui_agent_factories[uri] = agui_agent + else: + self._agents[uri] = agui_agent + self._app.add_api_route(uri, self._ag_ui_agent_endpoint, methods=["POST"], response_model=None) + + def set_fastapi(self, app: FastAPI): + """Set the FastAPI app for the service. + + This method allows setting a custom FastAPI application instance for the service. + It is recommended to avoid using this method unless necessary, as it may cause + issues in multi-process environments. + + Args: + app: The FastAPI application instance to register with the service + + Raises: + ValueError: If the app is not a FastAPI instance + + Note: + Example usage for adding custom routes: + + ```python + from fastapi import Request + from trpc_fastapi import fastapi_route + + @fastapi_route("/health_check", ["GET"], route_params={'response_model': dict}) + async def health_check(request: Request) -> dict: + return {"message": "Health check success!"} + ``` + """ + self._app = app + + async def _ag_ui_agent_endpoint(self, input_data: RunAgentInput, request: Request): + """AG-UI agent endpoint with tRPC context and filters.""" + # Get the accept header from the request + accept_header = request.headers.get("accept") + logger.info("accept_header: %s", request.url.path) + + # Create an event encoder to properly format SSE events + encoder = EventEncoder(accept=accept_header) + if request.url.path not in self._agents: + raise HTTPException(status_code=404, detail=f"Agent not found for path: {request.url.path}") + agui_agent: AgUiAgent = self._agents[request.url.path] + + return StreamingResponse(event_generator(request, agui_agent, input_data, encoder), + media_type=encoder.get_content_type()) diff --git a/trpc_agent_sdk/server/ag_ui/_plugin/_utils.py b/trpc_agent_sdk/server/ag_ui/_plugin/_utils.py new file mode 100644 index 000000000..06b3820b3 --- /dev/null +++ b/trpc_agent_sdk/server/ag_ui/_plugin/_utils.py @@ -0,0 +1,80 @@ +# 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. +"""AG-UI agent endpoint with tRPC context and filters.""" + +import asyncio + +from ag_ui.core import EventType +from ag_ui.core import RunAgentInput +from ag_ui.core import RunErrorEvent +from ag_ui.encoder import EventEncoder +from fastapi import Request +from trpc_agent_sdk.log import logger + +from .._core import AgUiAgent + + +async def event_generator(request: Request, agui_agent: AgUiAgent, input_data: RunAgentInput, encoder: EventEncoder): + """Generate events from AG-UI agent.""" + try: + app_name = agui_agent.get_app_name(input_data) + user_id = agui_agent.get_user_id(input_data) + session_id = input_data.thread_id + # Get tRPC context from contextvars (injected by middleware) + async for event in agui_agent.run(input_data, http_request=request): + # Check for client disconnect periodically + if await request.is_disconnected(): + logger.info("Client disconnected for thread %s", session_id) + break + + try: + encoded = encoder.encode(event) + logger.debug("HTTP Response: %s", encoded) + yield encoded + except Exception as encoding_error: # pylint: disable=broad-except + # Handle encoding-specific errors + logger.error("❌ Event encoding error: %s", encoding_error, exc_info=True) + # Create a RunErrorEvent for encoding failures + error_event = RunErrorEvent( + type=EventType.RUN_ERROR, + message=f"Event encoding failed: {str(encoding_error)}", + code="ENCODING_ERROR", + ) + try: + error_encoded = encoder.encode(error_event) + yield error_encoded + except Exception: # pylint: disable=broad-except + # If we can't even encode the error event, yield a basic SSE error + logger.error("Failed to encode error event, yielding basic SSE error") + yield 'event: error\\ndata: {\\"error\\": \\"Event encoding failed\\"}\\n\\n' + break # Stop the stream after an encoding error + except asyncio.CancelledError: + # Connection was closed by client + logger.info("Connection cancelled for thread %s", session_id) + raise + except Exception as agent_error: # pylint: disable=broad-except + # Handle errors from AgUiAgent.run() itself + logger.error("❌ AgUiAgent error: %s", agent_error, exc_info=True) + try: + error_event = RunErrorEvent( + type=EventType.RUN_ERROR, + message=f"Agent execution failed: {str(agent_error)}", + code="AGENT_ERROR", + ) + error_encoded = encoder.encode(error_event) + yield error_encoded + except Exception: # pylint: disable=broad-except + # If we can't encode the error event, yield a basic SSE error + logger.error("Failed to encode agent error event, yielding basic SSE error") + yield 'event: error\\ndata: {\\"error\\": \\"Agent execution failed\\"}\\n\\n' + finally: + # Trigger cancellation of the background TRPC run + # Uses the configured cancel_wait_timeout from AgUiAgent + await agui_agent.cancel_run( + session_id=session_id, + app_name=app_name, + user_id=user_id, + ) diff --git a/trpc_agent_sdk/server/agents/__init__.py b/trpc_agent_sdk/server/agents/__init__.py new file mode 100644 index 000000000..3f653a71f --- /dev/null +++ b/trpc_agent_sdk/server/agents/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Agents module for TRPC Agent framework.""" diff --git a/trpc_agent_sdk/server/agents/claude/__init__.py b/trpc_agent_sdk/server/agents/claude/__init__.py new file mode 100644 index 000000000..e0a6b6543 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/__init__.py @@ -0,0 +1,75 @@ +# 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. +# +"""Since there may be multiple services in this directory, we do not import them here. +Users should explicitly import the specific classes they need from the corresponding files to avoid unnecessary imports. +""" + +# Import McpServer to make it available at runtime for Pydantic model validation +# The claude_agent_sdk only imports it under TYPE_CHECKING, causing runtime errors + +import claude_agent_sdk.types +from mcp.server import Server as McpServer + +from ._claude_agent import ClaudeAgent +from ._proxy import AddModelRequest +from ._proxy import AddModelResponse +from ._proxy import AnthropicMessage +from ._proxy import AnthropicMessagesRequest +from ._proxy import AnthropicMessagesResponse +from ._proxy import AnthropicProxyApp +from ._proxy import AnthropicTool +from ._proxy import ContentBlockImage +from ._proxy import ContentBlockText +from ._proxy import ContentBlockToolResult +from ._proxy import ContentBlockToolUse +from ._proxy import DeleteModelRequest +from ._proxy import DeleteModelResponse +from ._proxy import SystemContent +from ._proxy import TokenCountRequest +from ._proxy import TokenCountResponse +from ._proxy import Usage +from ._proxy_logger import ProxyLogger +from ._proxy_logger import get_proxy_logger +from ._runtime import AsyncRuntime +from ._session_config import SessionConfig +from ._session_manager import SessionManager +from ._setup import destroy_claude_env +from ._setup import setup_claude_env + +__all__ = [ + "McpServer", + "ClaudeAgent", + "AddModelRequest", + "AddModelResponse", + "AnthropicMessage", + "AnthropicMessagesRequest", + "AnthropicMessagesResponse", + "AnthropicProxyApp", + "AnthropicTool", + "ContentBlockImage", + "ContentBlockText", + "ContentBlockToolResult", + "ContentBlockToolUse", + "DeleteModelRequest", + "DeleteModelResponse", + "SystemContent", + "TokenCountRequest", + "TokenCountResponse", + "Usage", + "ProxyLogger", + "get_proxy_logger", + "AsyncRuntime", + "SessionConfig", + "SessionManager", + "destroy_claude_env", + "setup_claude_env", +] + +# Inject McpServer into claude_agent_sdk.types namespace so Pydantic can resolve it +claude_agent_sdk.types.McpServer = McpServer +# Rebuild the ClaudeAgent model with McpServer now available +ClaudeAgent.model_rebuild() diff --git a/trpc_agent_sdk/server/agents/claude/_claude_agent.py b/trpc_agent_sdk/server/agents/claude/_claude_agent.py new file mode 100644 index 000000000..d745c6117 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_claude_agent.py @@ -0,0 +1,1241 @@ +# 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. +# +"""Claude agent for TRPC Agent framework.""" + +import asyncio +import inspect +import re +import sys +from pathlib import Path +from typing import Any +from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import List +from typing import Optional +from typing import Set +from typing import Union +from typing_extensions import override + +from claude_agent_sdk import AssistantMessage +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKClient +from claude_agent_sdk import ResultMessage +from claude_agent_sdk import SdkMcpTool +from claude_agent_sdk import SystemMessage +from claude_agent_sdk import TextBlock +from claude_agent_sdk import ThinkingBlock +from claude_agent_sdk import ToolResultBlock +from claude_agent_sdk import ToolUseBlock +from claude_agent_sdk import UserMessage +from claude_agent_sdk import create_sdk_mcp_server +from claude_agent_sdk.types import StreamEvent +from pydantic import ConfigDict +from pydantic import Field + +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.agents import InstructionProvider +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import TOOL_STREAMING_ARGS +from trpc_agent_sdk.telemetry import CustomTraceReporter +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.tools import FunctionTool +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import Type + +from ._runtime import AsyncRuntime +from ._session_config import SessionConfig +from ._session_manager import SessionManager +from ._setup import _add_model +from ._setup import _delete_model + +# Type alias for tool definitions +ToolUnion = Union[BaseTool, BaseToolSet, Callable] + + +class ClaudeAgent(BaseAgent): + """Claude Agent integration for TRPC Agent framework. + + This agent integrates Anthropic's Claude Code SDK with the TRPC Agent framework, + enabling powerful agentic workflows with Claude models through a proxy server. + + Features: + - Model configuration and automatic proxy registration + - System instruction with template substitution + - Tool integration via MCP servers (tools are automatically converted) + - Session state management + - Streaming support via ClaudeSDKClient + + Configuration: + - claude_agent_options: ClaudeAgentOptions from claude_agent_sdk that will be merged + with the agent's model, instruction, and tools properties + """ + + model_config = ConfigDict(arbitrary_types_allowed=True, ) + """Pydantic model config.""" + + model: Union[LLMModel, Callable[[dict[str, Any]], Awaitable[LLMModel]]] + """Model to use. Must be an LLMModel instance or async factory callback. + + Can be either: + - An LLMModel instance (static model) + - An async factory callback that creates a model per-request + + The model will be automatically added to the proxy server and the returned + model key will be used for Claude API calls. + """ + + instruction: Union[str, InstructionProvider] = "" + """System instruction/prompt for Claude. + + Supports template substitution from session state using {variable} syntax. + Example: "You are assisting {user_name} in {user_city}" + + Can also be a callable that accepts InvocationContext and returns a string. + """ + + tools: List[ToolUnion] = Field(default_factory=list) + """Tools available to this agent. + + Can be a list of: + - Callable functions (will be wrapped in FunctionTool) + - BaseTool instances (used directly) + - BaseToolSet instances (will be expanded to individual tools) + + Tools are automatically converted to Claude SDK MCP tools and registered with + the proxy server. + """ + + claude_agent_options: Optional[ClaudeAgentOptions] = None + """Optional ClaudeAgentOptions to configure the agent. + + These options will be merged with the agent's model, instruction, and tools properties. + If not provided, default options will be used. + """ + + generate_content_config: Optional[GenerateContentConfig] = None + """The additional content generation configurations. + + This config will be sent to the proxy server along with the model. + When the proxy server builds the generation config, it will: + 1. model_copy(deep=True) this config + 2. Set fields from AnthropicMessagesRequest if they are not already set in the config + + For example: use this config to adjust model temperature, top_p, top_k, max_tokens, etc. + + NOTE: not all fields are usable, e.g. tools must be configured via `tools`, + system_instruction must be configured via `instruction`. + """ + + output_key: Optional[str] = None + """Key in session state to store agent output for later use.""" + + enable_session: bool = False + """Whether to enable tRPC-Agent's session for multi-turn conversation. + + If True, the agent will use tRPC-Agent's session to manage conversation history. + Suitable for service deployments using Session Storage Service(Redis/Mysql). + If False (default), Claude will maintain its own conversation history for each user. + Suitable for service deployments using hash-based naming. + """ + + session_config: Optional[SessionConfig] = None + """Configuration for SessionManager behavior when enable_session is False. + + Controls aspects like session TTL (time-to-live) for idle session cleanup. + If not provided, default configuration will be used. + """ + + # Internal state + _resolved_model_key: Optional[str] = None + """Cached model key after adding LLMModel to proxy.""" + + _last_model_name: Optional[str] = None + """Track last resolved model name for cache invalidation.""" + + _runtime: Optional[AsyncRuntime] = None + """Async runtime for executing async operations in dedicated event loop thread.""" + + _session_manager: Optional[SessionManager] = None + """Session manager for this agent instance.""" + + _streaming_tool_names: Optional[Set[str]] = None + """Set of tool names that support streaming arguments. + + This is populated during _run_async_impl by detecting tools with is_streaming=True. + Only tools in this set will receive streaming events, matching LlmAgent behavior. + """ + + def __init__(self, **data): + """Initialize the Claude agent. + + Note: Runtime initialization is deferred to initialize() method. + """ + super().__init__(**data) + + def initialize(self) -> None: + """Initialize runtime resources. + + Creates AsyncRuntime and SessionManager if enable_session is False (Claude manages history). + This method should be called before using the agent. + """ + # Initialize runtime only when enable_session is False + # (Claude maintains its own conversation history per session) + if not self.enable_session: + if self._runtime is None: + self._runtime = AsyncRuntime(thread_name="ClaudeAgent") + self._runtime.start() + logger.debug("AsyncRuntime event loop thread started") + + if self._session_manager is None: + # Use session_config if provided, otherwise use defaults + self._session_manager = SessionManager( + runtime=self._runtime, + config=self.session_config, + ) + ttl = self.session_config.ttl if self.session_config else SessionConfig().ttl + logger.debug("SessionManager initialized for Claude-managed conversation history (ttl=%ss)", ttl) + + def destroy(self) -> None: + """Destroy runtime resources. + + Closes the session manager, cleans up all connected clients, and shuts down the runtime. + This method should be called when the agent is no longer needed. + """ + # Close session manager first + if self._session_manager is not None: + try: + self._session_manager.close() + logger.debug("SessionManager closed and resources cleaned up") + except Exception as ex: # pylint: disable=broad-except + logger.error("Error closing SessionManager: %s", ex, exc_info=True) + finally: + self._session_manager = None + + # Shutdown runtime + if self._runtime is not None: + try: + self._runtime.shutdown() + logger.debug("AsyncRuntime shutdown completed") + except Exception as ex: # pylint: disable=broad-except + logger.error("Error shutting down AsyncRuntime: %s", ex, exc_info=True) + finally: + self._runtime = None + + @override + async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]: + """Core implementation of Claude agent execution. + + Args: + ctx: Invocation context with session, events, and configuration + + Yields: + Event: Events from Claude's responses including text, tool calls, and results + """ + # Ensure runtime is initialized + if not self.enable_session and self._session_manager is None: + self.initialize() + + # Detect streaming tools before execution (align with LlmAgent behavior) + self._streaming_tool_names = await self._detect_streaming_tools(ctx) + + # Ensure model is ready before execution + model_name = await self._ensure_model_ready(ctx) + + # CHECKPOINT 1: At method entry, after model ready + await ctx.raise_if_cancelled() + + # Parse agent configuration and build Claude options + claude_options = await self._parse_agent_config(ctx, model_name) + + # Get message content - check override_messages first, then fall back to session events + if ctx.override_messages is not None: + # Mode 1: Use override_messages (controlled by TeamAgent or similar) + user_prompt = self._convert_override_messages_to_prompt(ctx.override_messages) + logger.debug("Using override_messages for Claude agent: %s", self.name) + elif self.enable_session: + # Mode 2: Build full conversation history from TRPC session + user_prompt = self._build_prompt_with_history(ctx) + else: + # Mode 3: Use only the latest user message (Claude maintains its own conversation context) + user_prompt = self._extract_latest_user_message(ctx) + + if not user_prompt: + logger.warning("No user message found in events") + return + + logger.debug("Sending prompt to Claude: %s...", user_prompt[:100]) + + # Determine session ID based on history management strategy + if self.enable_session: + # We're managing history ourselves via the trpc-session, so set session id to default. + # When set to default, it will act as a new session every time query is called. + claude_session_id = "default" + else: + # Let Claude manage history using tRPC-Agent's session ID. + claude_session_id = ctx.session.id if ctx.session and ctx.session.id else "default" + + logger.debug("Using claude_session_id: %s (enable_session=%s)", claude_session_id, self.enable_session) + + # Create trace reporter for telemetry + def _text_filter(text: str) -> bool: + """Filter out placeholder text content.""" + return text and text != "(no content)" + + trace_reporter = CustomTraceReporter( + agent_name=self.name, + model_prefix="claude", + tool_description_prefix="Claude tool", + text_content_filter=_text_filter, + ) + + # Get or create a persistent client for this Claude session + try: + # Only use session manager when enable_session is False + if not self.enable_session: + tool_use_map = {} # {tool_use_id: function_name} + tool_info_by_index: dict[int, dict] = {} # {index: {id, name}} + + async for message in self._session_manager.stream_query( + session_id=claude_session_id, + options=claude_options, + prompt=user_prompt, + ): + # CHECKPOINT 2: Each streaming message + await ctx.raise_if_cancelled() + + logger.debug("Received message from Claude: %s", type(message).__name__) + + event = self._convert_message_to_event(ctx, message, tool_use_map, tool_info_by_index) + + if event: + # Trace event + trace_reporter.trace_event(ctx, event) + + if event.is_final_response(): + self._save_output_to_state(ctx, event) + yield event + else: + # For enable_session=True, we need to run in the current event loop + # Use async context manager for proper lifecycle + async with ClaudeSDKClient(options=claude_options) as client: + logger.debug("Created new client for session '%s' (enable_session=True)", claude_session_id) + + await client.query(user_prompt, session_id=claude_session_id) + + tool_use_map = {} # {tool_use_id: function_name} + tool_info_by_index: dict[int, dict] = {} # {index: {id, name}} + + async for message in client.receive_response(): + # CHECKPOINT 2: Each streaming message + await ctx.raise_if_cancelled() + + logger.debug("Received message from Claude: %s", type(message).__name__) + + event = self._convert_message_to_event(ctx, message, tool_use_map, tool_info_by_index) + + if event: + # Trace event + trace_reporter.trace_event(ctx, event) + + if event.is_final_response(): + self._save_output_to_state(ctx, event) + yield event + + except RunCancelledException: + # Re-raise to let Runner handle cleanup + raise + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error during Claude query: %s", ex, exc_info=True) + # Yield error event + error_event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content( + role="model", + parts=[Part.from_text(text=f"Error: {str(ex)}")], + ), + partial=False, + ) + yield error_event + + async def _ensure_model_ready(self, ctx: InvocationContext) -> str: + """Ensure model is ready for use, handling factory callbacks and caching. + + Adds the LLMModel to the proxy server and caches the result. + + For static models (non-callback): + - Cache is reused if already set (no need to re-add) + + For callable models (create_model callback): + - Always delete old model key (if exists) before adding new model + - This ensures fresh model registration on each invocation + + Args: + ctx: Invocation context with run_config.custom_data + + Returns: + str: Model name/key to use in Claude API calls + + Raises: + ValueError: If proxy server is not ready or model addition fails + """ + # Check if model is a callable (create_model callback) + is_callback = callable(self.model) + + # For static models, return cached key if available + if not is_callback and self._resolved_model_key: + logger.debug("Reusing cached model key for static model") + return self._resolved_model_key + + # Add model to proxy (new model or replacing old one for callbacks) + try: + # Resolve model (may invoke model creation callback for callable models) + model_instance = self.model + if is_callback: + custom_data = ctx.run_config.custom_data if ctx.run_config else {} + model_instance = await self.model(custom_data) + + # For callable models, delete the old model key if it exists + if self._resolved_model_key: + logger.debug("Deleting old model key '%s' before adding new model", self._resolved_model_key) + try: + _delete_model(self._resolved_model_key) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to delete old model '%s': %s", self._resolved_model_key, ex) + + # Add model to proxy and get key, passing generate_content_config + model_key = _add_model(model_instance, self.generate_content_config) + self._resolved_model_key = model_key + self._last_model_name = model_instance.name + logger.debug("Added LLMModel '%s' to proxy, got key: %s", model_instance.name, model_key) + return model_key + + except RuntimeError as ex: + logger.error("Failed to add model to proxy server: %s", ex, exc_info=True) + raise ValueError(f"Failed to add model to proxy server. " + f"Make sure setup_claude_env() was called first. Error: {ex}") from ex + + async def _detect_streaming_tools(self, ctx: InvocationContext) -> Set[str]: + """Detect which tools support streaming arguments. + + This method checks each tool's is_streaming property to determine which + tools should receive streaming events. This aligns ClaudeAgent behavior + with LlmAgent. + + Note: ClaudeAgent converts tools to MCP format with naming convention: + "mcp__{agent_name}_tools__{tool_name}". This method stores both the + original tool name and the MCP-prefixed name to ensure matching works + correctly when Claude returns tool calls. + + Args: + ctx: The invocation context + + Returns: + Set of tool names that support streaming (includes both original + and MCP-prefixed names) + """ + streaming_names: Set[str] = set() + + if not self.tools: + return streaming_names + + # MCP server name follows the pattern: {agent_name}_tools + mcp_server_name = f"{self.name}_tools" + + for tool_item in self.tools: + if isinstance(tool_item, BaseToolSet): + toolset_tools = await tool_item.get_tools(ctx) + for tool in toolset_tools: + if getattr(tool, "is_streaming", False): + # Add both original name and MCP-prefixed name + streaming_names.add(tool.name) + streaming_names.add(f"mcp__{mcp_server_name}__{tool.name}") + elif isinstance(tool_item, BaseTool): + if getattr(tool_item, "is_streaming", False): + # Add both original name and MCP-prefixed name + streaming_names.add(tool_item.name) + streaming_names.add(f"mcp__{mcp_server_name}__{tool_item.name}") + elif callable(tool_item): + func_name = getattr(tool_item, "__name__", str(tool_item)) + if getattr(tool_item, "is_streaming", False): + streaming_names.add(func_name) + streaming_names.add(f"mcp__{mcp_server_name}__{func_name}") + + if streaming_names: + logger.debug("Detected %d streaming tool entries: %s", len(streaming_names), streaming_names) + + return streaming_names + + async def _convert_tools_to_mcp(self, ctx: InvocationContext) -> Optional[tuple[dict, List[str]]]: + """Convert TRPC tools to Claude SDK MCP server configuration. + + Args: + ctx: The invocation context + + Returns: + Tuple of (mcp_servers dict, allowed_tools list), or None if no tools + """ + if not self.tools: + return None + + # Resolve tools - expand toolsets and convert callables to FunctionTool + resolved_tools: List[BaseTool] = [] + + for tool_item in self.tools: + if isinstance(tool_item, BaseToolSet): + # Expand toolset to individual tools + toolset_tools = await tool_item.get_tools(ctx) + resolved_tools.extend(toolset_tools) + elif isinstance(tool_item, BaseTool): + resolved_tools.append(tool_item) + elif callable(tool_item): + # Wrap callable in FunctionTool + function_tool = FunctionTool(tool_item) + resolved_tools.append(function_tool) + else: + logger.warning("Unsupported tool type: %s", type(tool_item)) + + if not resolved_tools: + return None + + # Convert TRPC tools to Claude SDK tools + sdk_tools: List[SdkMcpTool] = [] + tool_names: List[str] = [] + + for trpc_tool in resolved_tools: + try: + sdk_tool = self._convert_tool_to_sdk_tool(trpc_tool, ctx) + sdk_tools.append(sdk_tool) + tool_names.append(trpc_tool.name) + logger.debug("Converted tool '%s' to Claude SDK MCP tool", trpc_tool.name) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Failed to convert tool '%s': %s", trpc_tool.name, ex, exc_info=True) + + if not sdk_tools: + return None + + # Create MCP server with the tools + server_name = f"{self.name}_tools" + mcp_server = create_sdk_mcp_server( + name=server_name, + version="1.0.0", + tools=sdk_tools, + ) + + logger.debug("Created MCP server with %s tools for agent '%s'", len(sdk_tools), self.name) + + # Build allowed_tools list with mcp__servername__toolname format + allowed_tools = [f"mcp__{server_name}__{tool_name}" for tool_name in tool_names] + + return ({server_name: mcp_server}, allowed_tools) + + def _convert_tool_to_sdk_tool(self, trpc_tool: BaseTool, ctx: InvocationContext) -> SdkMcpTool: + """Convert a single TRPC BaseTool to Claude SDK SdkMcpTool. + + Args: + trpc_tool: The TRPC tool to convert + ctx: The invocation context + + Returns: + SdkMcpTool ready for use with Claude SDK + + Raises: + ValueError: If tool cannot be converted + """ + # Get the function declaration to extract parameter schema + func_decl = trpc_tool._get_declaration() + if not func_decl: + logger.error("Tool '%s' has no function declaration", trpc_tool.name) + raise ValueError(f"Tool '{trpc_tool.name}' has no function declaration") + + # Convert TRPC schema to Claude SDK input_schema + input_schema = self._convert_schema_to_dict(func_decl.parameters) if func_decl.parameters else {} + + # Capture the current event loop (the _run_async_impl loop) + target_loop = asyncio.get_event_loop() + + # Create async handler that wraps the TRPC tool's run_async + async def handler(args: dict[str, Any]) -> dict[str, Any]: + """Handler that executes the TRPC tool and formats the response.""" + try: + # Submit the tool execution to the target loop (_run_async_impl loop) + future = asyncio.run_coroutine_threadsafe(trpc_tool.run_async(tool_context=ctx, args=args), target_loop) + result = await asyncio.wrap_future(future) + + # Format result for Claude SDK + if isinstance(result, dict): + # If result already has content format, use it + if "content" in result: + return result + # Otherwise wrap it + result_text = str(result) + elif result is None: + result_text = "Success" + else: + result_text = str(result) + + return {"content": [{"type": "text", "text": result_text}]} + + except Exception as ex: # pylint: disable=broad-except + logger.error("Error executing tool '%s': %s", trpc_tool.name, ex, exc_info=True) + return { + "content": [{ + "type": "text", + "text": f"Error: {str(ex)}" + }], + "is_error": True, + } + + # Create and return SdkMcpTool + return SdkMcpTool( + name=trpc_tool.name, + description=trpc_tool.description or "", + input_schema=input_schema, + handler=handler, + ) + + def _convert_schema_to_dict(self, schema) -> dict[str, Any]: + """Convert TRPC Schema to dict format for Claude SDK. + + Args: + schema: TRPC Schema object + + Returns: + Dictionary mapping parameter names to types + """ + if not schema or not schema.properties: + return {} + + # Build a simple dict mapping param names to Python types + input_schema = {} + + for param_name, param_schema in schema.properties.items(): + # Extract type from schema + param_type = self._get_python_type_from_schema(param_schema) + input_schema[param_name] = param_type + + return input_schema + + def _get_python_type_from_schema(self, param_schema) -> type: + """Get Python type from TRPC Schema parameter. + + Args: + param_schema: Parameter schema object + + Returns: + Python type (str, int, float, bool, etc.) + """ + if not param_schema or not param_schema.type: + return str # Default to string + + schema_type = param_schema.type + + # Map TRPC Schema types to Python types + type_mapping = { + Type.STRING: str, + Type.INTEGER: int, + Type.NUMBER: float, + Type.BOOLEAN: bool, + Type.OBJECT: dict, + Type.ARRAY: list, + } + + return type_mapping.get(schema_type, str) + + def _get_entry_point_dir(self) -> Optional[str]: + """Get the directory of the entry point (main script). + + Returns: + Path to the entry point directory, or None if not found + """ + try: + # Try to get the main module + main_module = sys.modules.get("__main__") + if main_module and hasattr(main_module, "__file__"): + main_file = main_module.__file__ + if main_file: + entry_dir = str(Path(main_file).parent.resolve()) + return entry_dir + logger.warning("Failed to get entry point directory") + return None + + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to get entry point directory: %s", ex, exc_info=True) + return None + + async def _parse_agent_config(self, ctx: InvocationContext, model_name: str) -> ClaudeAgentOptions: + """Parse agent configuration and return ClaudeAgentOptions. + + This method merges the claude_agent_options constructor parameter with the agent's + model, instruction, and tools properties. + + Args: + ctx: The invocation context + model_name: The model name/key to use (from _ensure_model_ready) + + Returns: + ClaudeAgentOptions configured for this execution + """ + # Start with options from constructor if provided, otherwise create new one + if self.claude_agent_options: + options = self.claude_agent_options + else: + options = ClaudeAgentOptions() + + # Set cwd to entry point directory if not already set + if not options.cwd: + entry_point_dir = self._get_entry_point_dir() + if entry_point_dir: + options.cwd = entry_point_dir + logger.debug("Set cwd to entry point directory: %s", entry_point_dir) + + # Set the model name + options.model = model_name + + # Set streaming by using run_config + options.include_partial_messages = ctx.run_config.streaming + logger.debug("Set include_partial_messages=%s based on streaming config", options.include_partial_messages) + + # Process instruction with template substitution + if self.instruction: + if callable(self.instruction): + # InstructionProvider callable + if inspect.iscoroutinefunction(self.instruction): + system_prompt = await self.instruction(ctx) + else: + system_prompt = self.instruction(ctx) + else: + # String instruction with template substitution + system_prompt = self._apply_template_substitution(self.instruction, ctx) + + options.system_prompt = system_prompt + + # Convert and add tools as MCP server if configured + if self.tools: + tools_result = await self._convert_tools_to_mcp(ctx) + if tools_result: + mcp_servers, allowed_tools = tools_result + + # Merge with any existing mcp_servers from config + if isinstance(options.mcp_servers, dict): + mcp_servers.update(options.mcp_servers) + options.mcp_servers = mcp_servers + + # Merge with any existing allowed_tools + if options.allowed_tools: + allowed_tools = list(set(allowed_tools + options.allowed_tools)) # Deduplicate + + if allowed_tools: + options.allowed_tools = allowed_tools + logger.debug("Pre-approved %s tools: %s", len(allowed_tools), allowed_tools) + + logger.debug("Claude options: %s", options) + + return options + + def _save_output_to_state(self, ctx: InvocationContext, event: Event) -> None: + """Save agent output to session state if output_key is configured. + + Args: + ctx: The invocation context + event: The event containing the content to save + """ + if self.output_key and event.content and event.content.parts: + # Save output to state using the delta tracking system + result = "".join([part.text for part in event.content.parts if part.text]) + if result: # Only save non-empty results + ctx.state[self.output_key] = result + event.actions.state_delta[self.output_key] = result + logger.debug("Saved agent output to state key '%s': %s...", self.output_key, result[:100]) + + def _apply_template_substitution(self, instruction: str, ctx: InvocationContext) -> str: + """Apply template substitution to replace {key} placeholders with state values. + + Supports: + - {var} - Required variable, left as-is if not found + - {var?} - Optional variable, replaced with empty string if not found + + Args: + instruction: Instruction string with template placeholders + ctx: Invocation context with session state + + Returns: + Instruction with placeholders replaced + """ + if not instruction or "{" not in instruction: + return instruction + + # Get state from session + state_dict = ctx.session.state if ctx.session else {} + + try: + + def replace_placeholder(match): + """Replace a single placeholder with its value.""" + var_name = match.group().lstrip("{").rstrip("}").strip() + optional = False + + # Handle optional variables (ending with ?) + if var_name.endswith("?"): + optional = True + var_name = var_name.removesuffix("?") + + # Check if variable exists in state + if var_name in state_dict: + value = state_dict[var_name] + return str(value) if value is not None else "" + else: + if optional: + return "" + else: + # Leave placeholder unchanged for required vars + return match.group() + + # Match {variable_name} patterns + pattern = r"\{[^{}]*\}" + result = re.sub(pattern, replace_placeholder, instruction) + + if result != instruction: + logger.debug("Template substitution applied: %s... -> %s...", instruction[:50], result[:50]) + + return result + + except Exception as ex: # pylint: disable=broad-except + logger.error("Template substitution failed: %s", ex, exc_info=True) + return instruction + + def _extract_latest_user_message(self, ctx: InvocationContext) -> Optional[str]: + """Extract the latest user message from session events. + + Args: + ctx: Invocation context with session events + + Returns: + Latest user message text, or None if not found + """ + if not ctx.session or not ctx.session.events: + return None + + # Look through events in reverse to find latest user message + for event in reversed(ctx.session.events): + if event.author == "user" and event.content and event.content.parts: + for part in event.content.parts: + if part.text: + return part.text + + return None + + def _convert_override_messages_to_prompt(self, override_messages: list) -> Optional[str]: + """Convert override_messages (Content objects) to a prompt string for Claude. + + This method converts the Content objects from TeamAgent into a formatted + prompt string suitable for Claude SDK. + + Args: + override_messages: List of Content objects from TeamAgent + + Returns: + Formatted prompt string, or None if no valid content found + """ + prompt_parts = [] + + for content in override_messages: + if not isinstance(content, Content) or not content.parts: + continue + + role = content.role or "user" + + for part in content.parts: + if part.text: + if role == "user": + prompt_parts.append(f"User: {part.text}") + else: + prompt_parts.append(f"Assistant: {part.text}") + elif part.function_call: + # Include function call information as context + func_call = part.function_call + args_str = str(func_call.args) if func_call.args else "{}" + prompt_parts.append(f"[Called function '{func_call.name}' with args: {args_str}]") + elif part.function_response: + # Include function response information as context + func_resp = part.function_response + response_str = str(func_resp.response) if func_resp.response else "Success" + prompt_parts.append(f"[Function '{func_resp.name}' returned: {response_str}]") + + if not prompt_parts: + return None + + return "\n".join(prompt_parts) + + def _build_prompt_with_history(self, ctx: InvocationContext) -> Optional[str]: + """Build a prompt with full conversation history from TRPC session. + + This method extracts the conversation history from session events and formats + it as a readable conversation string, suitable for multi-instance deployments + where Claude's built-in history is not available. + + Args: + ctx: Invocation context with session events + + Returns: + Formatted prompt with conversation history, or None if no messages found + """ + if not ctx.session or not ctx.session.events: + return None + + conversation_parts = [] + latest_user_message = None + + # Iterate through events to build conversation history + for event in ctx.session.events: + if not event.content or not event.content.parts: + continue + + if event.author == "user": + # Extract user message + for part in event.content.parts: + if part.text: + # Store the latest user message separately + latest_user_message = part.text + # Also add to history (except the very last one, we'll add it separately) + conversation_parts.append(f"User: {part.text}") + break + + elif event.author == self.name: + # Extract agent response + agent_message = self._format_agent_message_parts(event.content.parts) + if agent_message: + conversation_parts.append(f"Assistant: {agent_message}") + + if not latest_user_message: + return None + + # If there's conversation history (more than just the latest message) + if len(conversation_parts) > 1: + # Remove the latest user message from history (we'll add it separately) + history_parts = conversation_parts[:-1] + + # Build the final prompt with history + prompt = "Previous conversation:\n" + prompt += "\n".join(history_parts) + prompt += f"\n\nCurrent message:\nUser: {latest_user_message}" + + logger.debug("Built prompt with %s history messages", len(history_parts)) + return prompt + else: + # No history, just return the latest message + return latest_user_message + + def _format_agent_message_parts(self, parts: List[Part]) -> str: + """Format agent message parts into a readable string. + + Args: + parts: List of Part objects from an agent message + + Returns: + Formatted string representation of the message + """ + formatted_parts = [] + + for part in parts: + if part.text: + # Regular text response + formatted_parts.append(part.text) + + elif part.thought: + # Thinking/reasoning + formatted_parts.append(f"[Thinking: {part.thought}]") + + elif part.function_call: + # Function call + func_call = part.function_call + args_str = str(func_call.args) if func_call.args else "{}" + formatted_parts.append(f"[Called function '{func_call.name}' with args: {args_str}]") + + elif part.function_response: + # Function response + func_resp = part.function_response + response_str = str(func_resp.response) if func_resp.response else "Success" + formatted_parts.append(f"[Function '{func_resp.name}' returned: {response_str}]") + + return " ".join(formatted_parts) if formatted_parts else "" + + def _convert_message_to_event( + self, + ctx: InvocationContext, + message, + tool_use_map: dict[str, str], + tool_info_by_index: Optional[dict[int, dict]] = None, + ) -> Optional[Event]: + """Convert Claude SDK message to TRPC Event. + + Args: + ctx: Invocation context + message: Claude SDK message (AssistantMessage, SystemMessage, ResultMessage, StreamEvent, UserMessage, etc.) + tool_use_map: Mapping from tool_use_id to function name + tool_info_by_index: Optional dict mapping tool index to tool info for streaming tool calls. + Each entry contains: {"id": str, "name": str} + + Returns: + Event or None if message type is not supported + """ + if isinstance(message, AssistantMessage): + return self._convert_assistant_message(ctx, message, tool_use_map) + + elif isinstance(message, UserMessage): + return self._convert_user_message(ctx, message, tool_use_map) + + elif isinstance(message, StreamEvent): + return self._convert_streaming_event(ctx, message, tool_info_by_index) + + elif isinstance(message, SystemMessage): + # System messages are informational, can log or skip + logger.debug("System message: %s - %s", message.subtype, message.data) + return None + + elif isinstance(message, ResultMessage): + # Result message contains cost and usage info + logger.debug("Claude query complete: turns=%s, duration=%sms, cost=$%s (if available)", message.num_turns, + message.duration_ms, message.total_cost_usd) + return None + + else: + logger.debug("Unhandled message type: %s", type(message)) + return None + + def _convert_assistant_message(self, ctx: InvocationContext, message: AssistantMessage, + tool_use_map: dict[str, str]) -> Optional[Event]: + """Convert AssistantMessage to TRPC Event. + + Args: + ctx: Invocation context + message: AssistantMessage from Claude SDK + tool_use_map: Mapping from tool_use_id to function name (will be updated) + + Returns: + Event or None + """ + parts = [] + + for block in message.content: + if isinstance(block, TextBlock): + parts.append(Part.from_text(text=block.text)) + + elif isinstance(block, ThinkingBlock): + # Include thinking as thought part + parts.append(Part.from_thought(thought=block.thinking)) + + elif isinstance(block, ToolUseBlock): + # Convert to function call + func_call = Part.from_function_call(name=block.name, args=block.input) + func_call.function_call.id = block.id + parts.append(func_call) + + # Track tool_use_id to function name mapping + tool_use_map[block.id] = block.name + + elif isinstance(block, ToolResultBlock): + # Convert to function response + # Parse content to dict if string + if isinstance(block.content, str): + response = {"result": block.content} + elif isinstance(block.content, list): + # Extract text from list of content blocks + text_parts = [] + for item in block.content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + response = {"result": "\n".join(text_parts)} + else: + response = block.content or {} + + func_resp = Part.from_function_response( + name="tool_result", # Placeholder name + response=response, + ) + func_resp.function_response.id = block.tool_use_id + parts.append(func_resp) + + if parts: + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=parts), + partial=False, + ) + return event + + return None + + def _convert_user_message(self, ctx: InvocationContext, message: UserMessage, + tool_use_map: dict[str, str]) -> Optional[Event]: + """Convert UserMessage to TRPC Event. + + UserMessage contains tool results being sent back to Claude. + + Args: + ctx: Invocation context + message: UserMessage from Claude SDK + tool_use_map: Mapping from tool_use_id to function name + + Returns: + Event or None + """ + parts = [] + + if isinstance(message.content, list): + for block in message.content: + if isinstance(block, ToolResultBlock): + # Parse content to dict if string + if isinstance(block.content, str): + response = {"result": block.content} + elif isinstance(block.content, list): + # Extract text from list of content blocks + text_parts = [] + for item in block.content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + response = {"result": "\n".join(text_parts)} + else: + response = block.content or {} + + # Get function name from tool_use_map + function_name = tool_use_map.get(block.tool_use_id, "tool_result") + + func_resp = Part.from_function_response( + name=function_name, + response=response, + ) + func_resp.function_response.id = block.tool_use_id + + parts.append(func_resp) + logger.debug("Parsed tool result for tool_use_id=%s, function=%s", block.tool_use_id, function_name) + + if parts: + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=parts), + partial=False, + ) + return event + + return None + + def _convert_streaming_event( + self, + ctx: InvocationContext, + stream_event: StreamEvent, + tool_info_by_index: Optional[dict[int, dict]] = None, + ) -> Optional[Event]: + """Convert StreamEvent to TRPC Event. + + Args: + ctx: Invocation context + stream_event: StreamEvent from Claude SDK + tool_info_by_index: Optional dict mapping tool index to tool info (id, name). + Will be updated in-place for tool_use blocks. + + Returns: + Event or None. Emits events for text_delta (partial text chunks) and + input_json_delta (streaming tool call arguments). + The final complete message will come in an AssistantMessage. + """ + event_data = stream_event.event + event_type = event_data.get("type") + + # Handle content_block_start to track new tool_use blocks + if event_type == "content_block_start": + content_block = event_data.get("content_block", {}) + if content_block.get("type") == "tool_use" and tool_info_by_index is not None: + index = event_data.get("index", 0) + tool_info_by_index[index] = { + "id": content_block.get("id", ""), + "name": content_block.get("name", ""), + } + logger.debug(f"Stream: tool_use block started at index {index}, name={content_block.get('name')}") + return None + + # Handle content_block_delta for text and tool input streaming + if event_type == "content_block_delta": + delta = event_data.get("delta", {}) + delta_type = delta.get("type") + + if delta_type == "text_delta": + # Text content delta - emit as partial event + text_chunk = delta.get("text", "") + + if text_chunk: # Only emit if there's actual text + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=[Part.from_text(text=text_chunk)]), + partial=True, # Mark as partial for streaming + ) + return event + + elif delta_type == "input_json_delta": + # Tool input JSON delta - emit as streaming tool call event + index = event_data.get("index", 0) + partial_json = delta.get("partial_json", "") + + if tool_info_by_index is not None and index in tool_info_by_index and partial_json: + tool_info = tool_info_by_index[index] + tool_name = tool_info["name"] + + # Only emit streaming events for tools that have is_streaming=True + # This aligns ClaudeAgent behavior with LlmAgent + if self._streaming_tool_names and tool_name not in self._streaming_tool_names: + # Skip streaming events for non-streaming tools + return None + + # Create function call part with delta only + # Consumers handle streaming events through Runner.run_async() + function_part = Part.from_function_call(name=tool_name, args={TOOL_STREAMING_ARGS: partial_json}) + if tool_info["id"]: + function_part.function_call.id = tool_info["id"] + + event = Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=[function_part]), + partial=True, + custom_metadata={ + "streaming_tool_call": True, + "tool_call_args_complete": False, + }, + ) + return event + + return None + + # Log other event types for debugging but don't emit events + if event_type in ( + "message_start", + "content_block_stop", + "message_delta", + "message_stop", + ): + logger.debug("Stream: %s", event_type) + else: + logger.debug("Stream: unhandled event type %s", event_type) + + return None diff --git a/trpc_agent_sdk/server/agents/claude/_proxy.py b/trpc_agent_sdk/server/agents/claude/_proxy.py new file mode 100644 index 000000000..a916ee019 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_proxy.py @@ -0,0 +1,956 @@ +# 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. +# +"""Claude proxy for TRPC Agent framework.""" + +import base64 +import json +import uuid +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Dict +from typing import List +from typing import Literal +from typing import Optional +from typing import Union + +import cloudpickle as pickle +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +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.models import TOOL_STREAMING_ARGS +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Tool + +from ._proxy_logger import get_proxy_logger + + +# Anthropic API Request/Response Models +class ContentBlockText(BaseModel): + type: Literal["text"] + text: str + + +class ContentBlockImage(BaseModel): + type: Literal["image"] + source: Dict[str, Any] + + +class ContentBlockToolUse(BaseModel): + type: Literal["tool_use"] + id: str + name: str + input: Dict[str, Any] + + +class ContentBlockToolResult(BaseModel): + type: Literal["tool_result"] + tool_use_id: str + content: Union[str, List[Dict[str, Any]], Dict[str, Any]] + + +class SystemContent(BaseModel): + type: Literal["text"] + text: str + + +class AnthropicMessage(BaseModel): + role: Literal["user", "assistant"] + content: Union[ + str, + List[Union[ + ContentBlockText, + ContentBlockImage, + ContentBlockToolUse, + ContentBlockToolResult, + ]], + ] + + +class AnthropicTool(BaseModel): + name: str + description: Optional[str] = None + input_schema: Dict[str, Any] + + +class AnthropicMessagesRequest(BaseModel): + model: str + max_tokens: Optional[int] = None + messages: List[AnthropicMessage] + system: Optional[Union[str, List[SystemContent]]] = None + stop_sequences: Optional[List[str]] = None + stream: Optional[bool] = False + temperature: Optional[float] = None + top_p: Optional[float] = None + top_k: Optional[int] = None + tools: Optional[List[AnthropicTool]] = None + tool_choice: Optional[Dict[str, Any]] = None + + +class Usage(BaseModel): + input_tokens: int + output_tokens: int + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + + +class AnthropicMessagesResponse(BaseModel): + id: str + model: str + role: Literal["assistant"] = "assistant" + content: List[Union[ContentBlockText, ContentBlockToolUse]] + type: Literal["message"] = "message" + stop_reason: Optional[Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]] = None + stop_sequence: Optional[str] = None + usage: Usage + + +class TokenCountRequest(BaseModel): + model: str + messages: List[AnthropicMessage] + system: Optional[Union[str, List[SystemContent]]] = None + tools: Optional[List[AnthropicTool]] = None + tool_choice: Optional[Dict[str, Any]] = None + + +class TokenCountResponse(BaseModel): + input_tokens: int + + +class AddModelRequest(BaseModel): + model_data: str # Base64-encoded pickled model + config_data: Optional[str] = None # Base64-encoded pickled GenerateContentConfig + + +class AddModelResponse(BaseModel): + model: str # Model key + + +class DeleteModelRequest(BaseModel): + model_key: str # Model key to delete + + +class DeleteModelResponse(BaseModel): + success: bool + message: str + + +class AnthropicProxyApp: + """FastAPI application that proxies Anthropic API requests through LLMModel. + + This class is responsible for: + - Managing added LLMModel instances + - Converting between Anthropic API format and internal LlmRequest/LlmResponse + - Handling both streaming and non-streaming requests + + Lifecycle management (starting/stopping the server) is handled by the global module. + """ + + def __init__(self, claude_models: Optional[Dict[str, Union[LLMModel, Callable[[], Awaitable[LLMModel]]]]] = None): + """Initialize the proxy server application. + + Args: + claude_models: Dictionary of default models to pre-register. + Values can be either: + - LLMModel instances (used directly) + - Callable factories returning Awaitable[LLMModel] (called when needed) + Keys like "sonnet", "opus", "haiku" will be used for + pattern matching against claude-code model names. + If a model has generate_content_config, it will be + automatically extracted and stored. + """ + # Get proxy logger instance without setting it as default + # The subprocess will set it as default when initialized + self.logger = get_proxy_logger() + self.models: Dict[str, LLMModel] = {} + self.model_configs: Dict[str, GenerateContentConfig] = {} # Map model_key -> config + self.claude_models: Dict[str, Union[LLMModel, + Callable[[], + Awaitable[LLMModel]]]] = claude_models if claude_models else {} + + # Extract and store generate_content_config from claude_models if present + if self.claude_models: + for model_key, model_or_factory in self.claude_models.items(): + # Only extract config from LLMModel instances, not from factories + if isinstance(model_or_factory, LLMModel): + if hasattr(model_or_factory, + 'generate_content_config') and model_or_factory.generate_content_config: + self.model_configs[model_key] = model_or_factory.generate_content_config + self.logger.info("Extracted generate_content_config from model '%s'", model_key) + + self.app = FastAPI(title="Anthropic API Proxy") + + # Register routes + self._setup_routes() + + async def _resolve_model(self, model_name: str) -> Optional[LLMModel]: + """Resolve model name to an LLMModel instance. + + First checks for exact match in dynamically added models, + then checks for pattern match in claude default models + (e.g., "claude-sonnet-4-20250514" matches "sonnet"). + + If the model is a callable factory, it will be invoked to create the model instance. + + Args: + model_name: The model name from the request + + Returns: + LLMModel instance or None if not found + """ + # First try exact match in dynamically added models + if model_name in self.models: + self.logger.info("Exact model match in dynamically added models: %s", model_name) + return self.models[model_name] + + # Try pattern matching for claude-code default models + model_name_lower = model_name.lower() + for pattern in ["sonnet", "opus", "haiku"]: + if pattern in model_name_lower and pattern in self.claude_models: + self.logger.info("Pattern matched '%s' to claude default model '%s'", model_name, pattern) + model_or_factory = self.claude_models[pattern] + + # Check if it's a callable factory + if callable(model_or_factory) and not isinstance(model_or_factory, LLMModel): + self.logger.info("Invoking model factory for pattern '%s'", pattern) + model = await model_or_factory() + + # Extract generate_content_config from the created model if present and not already stored + if pattern not in self.model_configs: + if hasattr(model, 'generate_content_config') and model.generate_content_config: + self.model_configs[pattern] = model.generate_content_config + self.logger.info("Extracted generate_content_config from factory-created model '%s'", + pattern) + + return model + else: + return model_or_factory + + return None + + def _setup_routes(self): + """Set up FastAPI routes.""" + + @self.app.post("/v1/messages") + async def create_message(request: AnthropicMessagesRequest): # pylint: disable=unused-variable + try: + self.logger.info("Processing request: model=%s, stream=%s", request.model, request.stream) + + # Resolve model + model = await self._resolve_model(request.model) + if model is None: + self.logger.error("Model '%s' not found", request.model) + raise ValueError(f"Model '{request.model}' not found.") + + # Convert Anthropic request to LlmRequest + llm_request = self._convert_anthropic_to_llm_request(request, model) + + # Handle streaming mode + if request.stream: + return StreamingResponse( + self._handle_streaming(llm_request, request, model), + media_type="text/event-stream", + ) + else: + # Non-streaming mode + response = await self._handle_non_streaming(llm_request, request, model) + return response + + except Exception as ex: # pylint: disable=broad-except + self.logger.error("Error processing request: %s", ex, exc_info=True) + raise HTTPException(status_code=500, detail=str(ex)) + + @self.app.post("/v1/messages/count_tokens") + async def count_tokens(request: TokenCountRequest): # pylint: disable=unused-variable + try: + self.logger.info("Token count request: model=%s", request.model) + + # Resolve model + model = await self._resolve_model(request.model) + if model is None: + self.logger.error("Model '%s' not found", request.model) + raise ValueError(f"Model '{request.model}' not found.") + + # Convert to LlmRequest to get approximate token count + llm_request = self._convert_anthropic_to_llm_request( + AnthropicMessagesRequest( + model=request.model, + messages=request.messages, + system=request.system, + tools=request.tools, + tool_choice=request.tool_choice, + ), model) + + # Estimate tokens based on text content + # This is a simple approximation: ~4 characters per token + total_chars = 0 + + # Count system instruction + if llm_request.config and llm_request.config.system_instruction: + total_chars += len(str(llm_request.config.system_instruction)) + + # Count message content + for content in llm_request.contents: + for part in content.parts: + if part.text: + total_chars += len(part.text) + + # Count tool definitions if present + if llm_request.config and llm_request.config.tools: + for tool in llm_request.config.tools: + for func_decl in tool.function_declarations: + total_chars += len(func_decl.name or "") + total_chars += len(func_decl.description or "") + # Rough estimate for schema + if func_decl.parameters: + total_chars += 100 + + # Approximate token count (4 chars per token) + estimated_tokens = max(1, total_chars // 4) + + return TokenCountResponse(input_tokens=estimated_tokens) + + except Exception as ex: # pylint: disable=broad-except + self.logger.error("Error counting tokens: %s", ex, exc_info=True) + raise HTTPException(status_code=500, detail=str(ex)) + + @self.app.post("/add_model") + async def add_model_endpoint(request: AddModelRequest): # pylint: disable=unused-variable + """Add a model via HTTP by unpickling the serialized model data. + + Args: + request: Contains base64-encoded pickled model data and optional config data + + Returns: + JSON with model key: {"model": "model-key"} + """ + try: + self.logger.info("Received add_model request") + + # Decode base64 + pickled_data = base64.b64decode(request.model_data) + + # Unpickle the model + model = pickle.loads(pickled_data) + + # Verify it's an LLMModel + if not isinstance(model, LLMModel): + raise HTTPException(status_code=400, + detail=f"Invalid model type: {type(model)}. Expected LLMModel instance.") + + # Generate a 10-character UUID suffix (remove dashes and take first 10 chars) + uuid_suffix = uuid.uuid4().hex[:10] + # claude-code always pass model name in lowercase + model_key = f"{model.name}-{uuid_suffix}".lower() + self.models[model_key] = model + + # If config_data is provided, unpickle and store it + if request.config_data: + pickled_config = base64.b64decode(request.config_data) + config = pickle.loads(pickled_config) + self.model_configs[model_key] = config + self.logger.info("Config for model %s stored with key: %s", model.name, model_key) + + self.logger.info("Model %s added successfully with key: %s", model.name, model_key) + return AddModelResponse(model=model_key) + + except Exception as ex: # pylint: disable=broad-except + self.logger.error("Error adding model %s: %s", model.name, ex, exc_info=True) + raise HTTPException(status_code=500, detail=str(ex)) + + @self.app.post("/delete_model") + async def delete_model_endpoint(request: DeleteModelRequest): # pylint: disable=unused-variable + """Delete a model from the proxy server. + + Args: + request: Contains the model key to delete + + Returns: + JSON with success status: {"success": true, "message": "..."} + """ + try: + self.logger.info("Received delete_model request for key: %s", request.model_key) + + # Check if model exists in dynamically added models + if request.model_key not in self.models: + self.logger.warning("Model key '%s' not found in dynamically added models", request.model_key) + return DeleteModelResponse( + success=False, + message=f"Model key '{request.model_key}' not found", + ) + + # Delete the model + del self.models[request.model_key] + + # Also delete config if exists + if request.model_key in self.model_configs: + del self.model_configs[request.model_key] + self.logger.info("Deleted config for model key: %s", request.model_key) + + self.logger.info("Model with key '%s' deleted successfully", request.model_key) + return DeleteModelResponse( + success=True, + message=f"Model '{request.model_key}' deleted successfully", + ) + + except Exception as ex: # pylint: disable=broad-except + self.logger.error("Error deleting model: %s", ex, exc_info=True) + raise HTTPException(status_code=500, detail=str(ex)) + + @self.app.get("/") + async def root(): # pylint: disable=unused-variable + return {"message": "Anthropic API Proxy Server"} + + def _convert_anthropic_to_llm_request( + self, + request: AnthropicMessagesRequest, + model: LLMModel, + ) -> LlmRequest: + """Convert Anthropic request format to LlmRequest. + + Args: + request: Anthropic API request + model: The resolved LLMModel instance + + Returns: + LlmRequest object + """ + contents = [] + + # Convert messages to Content objects + for msg in request.messages: + parts = [] + + if isinstance(msg.content, str): + # Simple text message + parts.append(Part.from_text(text=msg.content)) + else: + # Complex message with content blocks + for block in msg.content: + if block.type == "text": + parts.append(Part.from_text(text=block.text)) + elif block.type == "image": + # Handle image - would need to convert source to inline_data + # For now, skip or add placeholder + self.logger.warning("Image content not fully supported yet") + elif block.type == "tool_use": + # Convert tool_use to function_call + part = Part.from_function_call(name=block.name, args=block.input) + # Set the id if available + if block.id: + part.function_call.id = block.id + parts.append(part) + elif block.type == "tool_result": + # Convert tool_result to function_response + # Note: Anthropic's tool_result doesn't include function name, + # so we use a placeholder. The ID is what matters for matching. + + # Normalize the content to a dictionary format + # FunctionResponse requires response to be a dict + response_dict = {} + if isinstance(block.content, dict): + response_dict = block.content + elif isinstance(block.content, str): + # Wrap string content in a dict + response_dict = {"result": block.content} + elif isinstance(block.content, list): + # Try to extract text from list or convert to dict + text_parts = [] + for item in block.content: + if isinstance(item, dict) and item.get("type") == "text": + text_parts.append(item.get("text", "")) + elif isinstance(item, str): + text_parts.append(item) + response_dict = {"result": "\n".join(text_parts) if text_parts else str(block.content)} + else: + # Fallback for other types + response_dict = {"result": str(block.content)} + + part = Part.from_function_response( + name="tool_response", # Placeholder name + response=response_dict, + ) + # Set the ID to match the tool_use_id + part.function_response.id = block.tool_use_id + parts.append(part) + + if parts: + content = Content(parts=parts, role=msg.role) + contents.append(content) + + # Try to get stored config for this model (by model name/key) + stored_config = self.model_configs.get(request.model) + request_fields = request.model_fields_set + + # If no stored config found, try to get config from the model itself + if not stored_config and hasattr(model, 'generate_content_config') and model.generate_content_config: + stored_config = model.generate_content_config + self.logger.debug("Using generate_content_config from model instance for %s", request.model) + + if stored_config: + # Deep copy the stored config + config = stored_config.model_copy(deep=True) + self.logger.debug("Using stored config for model %s", request.model) + + # Set fields from request if they are not already set in the config + # Only override if the config field is None/not set + if config.temperature is None and "temperature" in request_fields and request.temperature is not None: + config.temperature = request.temperature + if config.max_output_tokens is None and "max_tokens" in request_fields and request.max_tokens is not None: + config.max_output_tokens = request.max_tokens + if config.top_p is None and request.top_p is not None: + config.top_p = request.top_p + if config.top_k is None and request.top_k is not None: + config.top_k = request.top_k + if config.stop_sequences is None and request.stop_sequences is not None: + config.stop_sequences = request.stop_sequences + else: + # No stored config, build from request + config = GenerateContentConfig() + if "temperature" in request_fields and request.temperature is not None: + config.temperature = request.temperature + if "max_tokens" in request_fields and request.max_tokens is not None: + config.max_output_tokens = request.max_tokens + if request.top_p is not None: + config.top_p = request.top_p + if request.top_k is not None: + config.top_k = request.top_k + if request.stop_sequences is not None: + config.stop_sequences = request.stop_sequences + + # Add system instruction if present + if request.system: + if isinstance(request.system, str): + config.system_instruction = request.system + else: + # Concatenate system content blocks + system_text = "" + for block in request.system: + if block.type == "text": + system_text += block.text + "\n\n" + config.system_instruction = system_text.strip() + + # Convert tools if present + if request.tools: + tools = [] + for anthropic_tool in request.tools: + # Convert input_schema to Schema + schema = self._convert_dict_to_schema(anthropic_tool.input_schema) + + # Create FunctionDeclaration + func_decl = FunctionDeclaration( + name=anthropic_tool.name, + description=anthropic_tool.description or "", + parameters=schema, + ) + + # Create Tool with function_declarations + tool = Tool(function_declarations=[func_decl]) + tools.append(tool) + + config.tools = tools + + # When streaming is enabled, enable streaming for all tools + streaming_tool_names = None + if request.stream and request.tools: + streaming_tool_names = {t.name for t in request.tools} + + return LlmRequest(contents=contents, config=config, streaming_tool_names=streaming_tool_names) + + def _convert_dict_to_schema(self, schema_dict: Dict[str, Any]) -> Schema: + """Convert a dictionary schema to Schema object. + + Args: + schema_dict: Dictionary representation of schema + + Returns: + Schema object + """ + schema = Schema() + + if "type" in schema_dict: + schema.type = schema_dict["type"] + + if "description" in schema_dict: + schema.description = schema_dict["description"] + + if "properties" in schema_dict: + schema.properties = {} + for prop_name, prop_schema in schema_dict["properties"].items(): + schema.properties[prop_name] = self._convert_dict_to_schema(prop_schema) + + if "required" in schema_dict: + schema.required = schema_dict["required"] + + if "items" in schema_dict: + schema.items = self._convert_dict_to_schema(schema_dict["items"]) + + if "additionalProperties" in schema_dict: + schema.additional_properties = schema_dict["additionalProperties"] + + return schema + + async def _handle_non_streaming(self, llm_request: LlmRequest, original_request: AnthropicMessagesRequest, + model: LLMModel) -> AnthropicMessagesResponse: + """Handle non-streaming request. + + Args: + llm_request: Converted LlmRequest + original_request: Original Anthropic request + model: The LLMModel instance to use + + Returns: + Anthropic response + """ + # Call model + response_generator = model.generate_async(llm_request, stream=False) + + # Get the response + llm_response = None + async for resp in response_generator: + llm_response = resp + + if not llm_response: + self.logger.error("No response from model") + raise ValueError("No response from model") + + # Check for errors in response + if llm_response.error_code or llm_response.error_message: + error_msg = llm_response.error_message or f"Model error: {llm_response.error_code}" + self.logger.error("Model returned error: %s", error_msg) + raise HTTPException(status_code=500, detail=error_msg) + + # Convert to Anthropic format + return self._convert_llm_response_to_anthropic(llm_response, original_request) + + def _convert_llm_response_to_anthropic(self, llm_response: LlmResponse, + original_request: AnthropicMessagesRequest) -> AnthropicMessagesResponse: + """Convert LlmResponse to Anthropic format. + + Args: + llm_response: Response from LLMModel + original_request: Original request for context + + Returns: + Anthropic-formatted response + """ + content_blocks = [] + + if llm_response.content and llm_response.content.parts: + for part in llm_response.content.parts: + if part.text: + content_blocks.append(ContentBlockText(type="text", text=part.text)) + elif part.function_call: + content_blocks.append( + ContentBlockToolUse( + type="tool_use", + id=part.function_call.id or f"call_{uuid.uuid4().hex[:24]}", + name=part.function_call.name, + input=part.function_call.args, + )) + + # Ensure at least one content block + if not content_blocks: + content_blocks.append(ContentBlockText(type="text", text="")) + + # Determine stop reason + stop_reason = "end_turn" + if llm_response.error_code: + if llm_response.error_code == "length": + stop_reason = "max_tokens" + elif llm_response.error_code == "tool_calls": + stop_reason = "tool_use" + elif content_blocks and any(isinstance(block, ContentBlockToolUse) for block in content_blocks): + stop_reason = "tool_use" + + # Extract usage + input_tokens = 0 + output_tokens = 0 + if llm_response.usage_metadata: + input_tokens = llm_response.usage_metadata.prompt_token_count or 0 + output_tokens = llm_response.usage_metadata.candidates_token_count or 0 + + return AnthropicMessagesResponse( + id=f"msg_{uuid.uuid4().hex[:24]}", + model=original_request.model, + role="assistant", + content=content_blocks, + stop_reason=stop_reason, + stop_sequence=None, + usage=Usage(input_tokens=input_tokens, output_tokens=output_tokens), + ) + + async def _handle_streaming(self, llm_request: LlmRequest, original_request: AnthropicMessagesRequest, + model: LLMModel): + """Handle streaming request. + + Args: + llm_request: Converted LlmRequest + original_request: Original Anthropic request + model: The LLMModel instance to use + + Yields: + Server-sent events in Anthropic format + """ + try: + # Send message_start event + message_id = f"msg_{uuid.uuid4().hex[:24]}" + + message_data = { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "model": original_request.model, + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": { + "input_tokens": 0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + }, + }, + } + yield f"event: message_start\ndata: {json.dumps(message_data)}\n\n" + + # Start first content block (text) + content_block_start_event = { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "text", + "text": "" + }, + } + yield ("event: content_block_start\n" + f"data: {json.dumps(content_block_start_event)}\n\n") + + # Send ping + yield (f"event: ping\ndata: {json.dumps({'type': 'ping'})}\n\n") + + # Track state + text_block_closed = False + last_usage = None + # Track streaming tool calls: {tool_id: {"index": int, "name": str, "started": bool}} + streaming_tool_calls: Dict[str, Dict[str, Any]] = {} + next_tool_index = 0 # Next available tool block index (0 is text block) + + # Call model with streaming + response_generator = model.generate_async(llm_request, stream=True) + + def _is_streaming_tool_call(resp) -> bool: + """Check if response is a streaming tool call event.""" + if not resp.partial or not resp.content or not resp.content.parts: + return False + for part in resp.content.parts: + if part.function_call: + args = part.function_call.args or {} + if TOOL_STREAMING_ARGS in args: + return True + return False + + async for llm_response in response_generator: + # Handle streaming tool call events (partial tool arguments) + if _is_streaming_tool_call(llm_response): + for part in llm_response.content.parts: + if part.function_call: + tool_id = part.function_call.id or "" + tool_name = part.function_call.name + args = part.function_call.args or {} + delta_json = args.get(TOOL_STREAMING_ARGS, "") + + # Close text block if not already closed + if not text_block_closed: + text_block_closed = True + yield ("event: content_block_stop\n" + f'data: {json.dumps({"type": "content_block_stop", "index": 0})}\n\n') + + # Check if we need to start a new tool block + if tool_id not in streaming_tool_calls: + next_tool_index += 1 + tool_index = next_tool_index + streaming_tool_calls[tool_id] = { + "index": tool_index, + "name": tool_name, + "started": True, + } + + # Send content_block_start for tool_use + tool_start_event = { + "type": "content_block_start", + "index": tool_index, + "content_block": { + "type": "tool_use", + "id": tool_id or f"toolu_{uuid.uuid4().hex[:24]}", + "name": tool_name, + "input": {}, + }, + } + yield ("event: content_block_start\n" + f"data: {json.dumps(tool_start_event)}\n\n") + + # Send delta JSON + if delta_json: + tool_index = streaming_tool_calls[tool_id]["index"] + tool_delta_event = { + "type": "content_block_delta", + "index": tool_index, + "delta": { + "type": "input_json_delta", + "partial_json": delta_json + }, + } + yield ("event: content_block_delta\n" + f"data: {json.dumps(tool_delta_event)}\n\n") + + # Handle partial responses (streaming text only) + elif llm_response.partial and llm_response.content: + for part in llm_response.content.parts: + if part.text and not text_block_closed: + # Stream text deltas + text_delta_event = { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "text_delta", + "text": part.text + }, + } + yield ("event: content_block_delta\n" + f"data: {json.dumps(text_delta_event)}\n\n") + + # Handle final complete response (text + function calls) + elif not llm_response.partial: + # Close text block first + if not text_block_closed: + text_block_closed = True + yield ("event: content_block_stop\n" + f'data: {json.dumps({"type": "content_block_stop", "index": 0})}\n\n') + + # Close any streaming tool blocks that were started + for tool_id, tool_info in streaming_tool_calls.items(): + if tool_info.get("started") and not tool_info.get("closed"): + tool_info["closed"] = True + yield ( + "event: content_block_stop\n" + f'data: {json.dumps({"type": "content_block_stop", "index": tool_info["index"]})}\n\n') + + # Process function calls from final response (only those not already streamed) + if llm_response.content and llm_response.content.parts: + for part in llm_response.content.parts: + if part.function_call: + tool_id = part.function_call.id or "" + + # Skip if this tool was already handled via streaming + if tool_id in streaming_tool_calls: + continue + + # Tool calls that weren't streamed - send complete + next_tool_index += 1 + tool_index = next_tool_index + final_tool_id = tool_id or f"toolu_{uuid.uuid4().hex[:24]}" + + # Start tool use block + tool_start_event = { + "type": "content_block_start", + "index": tool_index, + "content_block": { + "type": "tool_use", + "id": final_tool_id, + "name": part.function_call.name, + "input": {}, + }, + } + yield ("event: content_block_start\n" + f"data: {json.dumps(tool_start_event)}\n\n") + + # Send complete tool input as JSON + args_json = json.dumps(part.function_call.args) + tool_delta_event = { + "type": "content_block_delta", + "index": tool_index, + "delta": { + "type": "input_json_delta", + "partial_json": args_json + }, + } + yield ("event: content_block_delta\n" + f"data: {json.dumps(tool_delta_event)}\n\n") + + # Close tool block + yield ("event: content_block_stop\n" + f'data: {json.dumps({"type": "content_block_stop", "index": tool_index})}\n\n') + + # Get usage + if llm_response.usage_metadata: + last_usage = llm_response.usage_metadata + + # Determine stop reason + stop_reason = "end_turn" + if llm_response.content: + has_tool_calls = any(part.function_call for part in llm_response.content.parts) + if has_tool_calls: + stop_reason = "tool_use" + + # Also check streaming tool calls + if streaming_tool_calls: + stop_reason = "tool_use" + + if llm_response.error_code == "length": + stop_reason = "max_tokens" + + # Send message_delta with stop reason and usage + output_tokens = last_usage.candidates_token_count if last_usage else 0 + message_delta_event = { + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": None + }, + "usage": { + "output_tokens": output_tokens + }, + } + yield ("event: message_delta\n" + f"data: {json.dumps(message_delta_event)}\n\n") + + # Send message_stop + yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n' + + # Send [DONE] + yield "data: [DONE]\n\n" + + except Exception as ex: # pylint: disable=broad-except + self.logger.error("Error in streaming: %s", str(ex)) + # Send error response to client + error_data = { + "type": "message_delta", + "delta": { + "stop_reason": "error", + "stop_sequence": None, + "error_detail": str(ex), # Include error details + }, + "usage": { + "output_tokens": 0 + }, + } + yield ("event: message_delta\n" + f"data: {json.dumps(error_data)}\n\n") + yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n' + yield "data: [DONE]\n\n" diff --git a/trpc_agent_sdk/server/agents/claude/_proxy_logger.py b/trpc_agent_sdk/server/agents/claude/_proxy_logger.py new file mode 100644 index 000000000..cfc49e5fe --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_proxy_logger.py @@ -0,0 +1,99 @@ +# 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. +# +"""Claude proxy logger for TRPC Agent framework.""" + +import logging +import os +from typing import Optional + +from trpc_agent_sdk.log import BaseLogger +from trpc_agent_sdk.log import DefaultLogger +from trpc_agent_sdk.log import LogLevel +from trpc_agent_sdk.log import register_logger +from trpc_agent_sdk.log import set_default_logger + + +class ProxyLogger(DefaultLogger): + """File-only logger for the Anthropic proxy server.""" + + def __init__( + self, + name: str = "anthropic_proxy", + log_file: str = "anthropic_proxy.log", + min_level: LogLevel = LogLevel.INFO, + ): + super().__init__(name=name, min_level=min_level) + self._log_file = log_file + + self._remove_console_handlers() + self._add_file_handler() + + def _remove_console_handlers(self) -> None: + for handler in list(self.logger.handlers): + if isinstance(handler, logging.StreamHandler): + self.logger.removeHandler(handler) + handler.close() + + def _add_file_handler(self) -> None: + absolute_path = os.path.abspath(self._log_file) + for handler in self.logger.handlers: + if isinstance(handler, logging.FileHandler) and os.path.abspath(getattr(handler, "baseFilename", + "")) == absolute_path: + return + + file_handler = logging.FileHandler(self._log_file) + file_handler.setLevel(self._convert_log_level(self.min_level)) + formatter = logging.Formatter( + "[%(asctime)s][%(levelname)s][%(name)s][%(filename)s:%(lineno)d][%(process)d] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + def debug(self, format_str: str, *args, **kwargs): + if self.min_level.value <= LogLevel.DEBUG.value: + kwargs.setdefault("stacklevel", 3) + super().debug(format_str, *args, **kwargs) + + def info(self, format_str: str, *args, **kwargs): + if self.min_level.value <= LogLevel.INFO.value: + kwargs.setdefault("stacklevel", 3) + super().info(format_str, *args, **kwargs) + + def warning(self, format_str: str, *args, **kwargs): + if self.min_level.value <= LogLevel.WARNING.value: + kwargs.setdefault("stacklevel", 3) + super().warning(format_str, *args, **kwargs) + + def error(self, format_str: str, *args, **kwargs): + if self.min_level.value <= LogLevel.ERROR.value: + kwargs.setdefault("stacklevel", 3) + super().error(format_str, *args, **kwargs) + + def fatal(self, format_str: str, *args, **kwargs): + if self.min_level.value <= LogLevel.FATAL.value: + kwargs.setdefault("stacklevel", 3) + super().fatal(format_str, *args, **kwargs) + + def with_fields(self, **kwargs) -> "ProxyLogger": + new_logger = ProxyLogger(name=self.name, log_file=self._log_file, min_level=self.min_level) + new_logger.extra_fields.update(self.extra_fields) + new_logger.extra_fields.update(kwargs) + return new_logger + + +_PROXY_LOGGER: Optional[ProxyLogger] = None + + +def get_proxy_logger(*, set_as_default: bool = False) -> BaseLogger: + global _PROXY_LOGGER + if _PROXY_LOGGER is None: + _PROXY_LOGGER = ProxyLogger() + register_logger(_PROXY_LOGGER.name, _PROXY_LOGGER) + if set_as_default: + set_default_logger(_PROXY_LOGGER) + return _PROXY_LOGGER diff --git a/trpc_agent_sdk/server/agents/claude/_runtime.py b/trpc_agent_sdk/server/agents/claude/_runtime.py new file mode 100644 index 000000000..f55e2e32a --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_runtime.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 Apache-2.0. +# +"""Claude runtime for TRPC Agent framework.""" + +import asyncio +import threading +from concurrent.futures import Future +from typing import Optional + +from trpc_agent_sdk.log import logger + + +class AsyncRuntime: + """Manages a dedicated event loop thread for async operations.""" + + def __init__(self, thread_name: str = "AsyncRuntime"): + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._loop_thread: Optional[threading.Thread] = None + self._thread_name = thread_name + self._loop_ready = threading.Event() + + def start(self) -> None: + + def run_loop() -> None: + logger.info("%s event loop thread started", self._thread_name) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + self._loop_ready.set() + try: + loop.run_forever() + finally: + _cancel_all_tasks(loop) + loop.close() + self._loop_ready.clear() + self._loop = None + logger.info("%s event loop thread stopped", self._thread_name) + + self._loop_ready.clear() + thread = threading.Thread(target=run_loop, daemon=True, name=self._thread_name) + self._loop_thread = thread + thread.start() + + self._loop_ready.wait() + + def submit_coroutine(self, coro) -> Future: + loop = self._ensure_loop() + return asyncio.run_coroutine_threadsafe(coro, loop) + + def shutdown(self) -> None: + loop = self._ensure_loop() + loop.call_soon_threadsafe(loop.stop) + + thread = self._loop_thread + if thread and thread.is_alive(): + thread.join(timeout=5.0) + if thread.is_alive(): + logger.warning("%s thread did not terminate within timeout", self._thread_name) + + logger.info("%s thread terminated successfully", self._thread_name) + + def _ensure_loop(self) -> asyncio.AbstractEventLoop: + if self._loop is None: + logger.error("%s event loop not initialized", self._thread_name) + raise RuntimeError(f"{self._thread_name} event loop not initialized") + return self._loop + + +def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: + pending = asyncio.all_tasks(loop) + if not pending: + return + + for task in pending: + task.cancel() + + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) diff --git a/trpc_agent_sdk/server/agents/claude/_session_config.py b/trpc_agent_sdk/server/agents/claude/_session_config.py new file mode 100644 index 000000000..ee9c54505 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_session_config.py @@ -0,0 +1,22 @@ +# 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. +# +"""Claude session configuration for TRPC Agent framework.""" + +from dataclasses import dataclass + + +@dataclass +class SessionConfig: + """Configuration for SessionManager/Session behavior. + + Attributes: + ttl: Time-to-live for idle sessions in seconds (default: 600s/10min) + Sessions that have been idle for longer than this will be automatically cleaned up. + Set to 0 or negative to disable TTL-based cleanup. + """ + + ttl: int = 600 diff --git a/trpc_agent_sdk/server/agents/claude/_session_manager.py b/trpc_agent_sdk/server/agents/claude/_session_manager.py new file mode 100644 index 000000000..fe8bfb432 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_session_manager.py @@ -0,0 +1,451 @@ +# 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. +# +"""Claude session manager for TRPC Agent framework.""" + +from __future__ import annotations + +import asyncio +import json +import queue +import threading +import time +from concurrent.futures import Future +from dataclasses import dataclass +from typing import AsyncGenerator +from typing import Dict +from typing import Optional + +from claude_agent_sdk import ClaudeAgentOptions +from claude_agent_sdk import ClaudeSDKClient + +from trpc_agent_sdk.log import logger + +from ._runtime import AsyncRuntime +from ._session_config import SessionConfig + +# Sentinel object used to signal the end of a response stream +_RESPONSE_SENTINEL = object() + + +@dataclass +class _SessionRequest: + """Represents a single query request to be processed by a session worker. + + Attributes: + prompt: The user's input prompt/message + session_id: Unique identifier for the Claude session + response_queue: Thread-safe queue for receiving response messages from the worker + cancelled: Thread-safe flag to signal cancellation to the worker + """ + + prompt: str + session_id: str + response_queue: "queue.Queue[object]" + cancelled: threading.Event = None + + def __post_init__(self): + """Initialize the cancellation event if not provided.""" + if self.cancelled is None: + self.cancelled = threading.Event() + + def is_cancelled(self) -> bool: + """Check if this request has been cancelled.""" + return self.cancelled.is_set() + + def cancel(self) -> None: + """Signal cancellation for this request.""" + self.cancelled.set() + + +@dataclass +class _SessionState: + """Tracks the state of a managed Claude session. + + Attributes: + worker: The session worker managing the ClaudeSDKClient lifecycle + options_signature: Fingerprint of the ClaudeAgentOptions for cache validation + last_access: Timestamp of last access, used for TTL-based cleanup + """ + + worker: "_SessionWorker" + options_signature: str + last_access: float + + +def _fingerprint_options(options: ClaudeAgentOptions) -> str: + """Generate a unique fingerprint string from ClaudeAgentOptions. + + This is used to detect when options have changed, requiring a new client. + Tries multiple serialization methods for compatibility with different Pydantic versions. + + Args: + options: The ClaudeAgentOptions to fingerprint + + Returns: + A stable JSON string representation of the options + """ + try: + return options.model_dump_json() + except AttributeError: + try: + dump = options.model_dump() + except AttributeError: + try: + return options.json() + except AttributeError: + return repr(options) + else: + return json.dumps(dump, sort_keys=True, default=str) + + +class _SessionWorker: + """Long-lived async task that owns a ClaudeSDKClient lifecycle. + + Each session worker runs in the AsyncRuntime's event loop and maintains a persistent + ClaudeSDKClient connection. It processes requests sequentially from a queue, allowing + multiple queries to reuse the same client and maintain conversation context. + """ + + def __init__(self, session_id: str, options: ClaudeAgentOptions): + self._session_id = session_id + self._options = options + self._request_queue: asyncio.Queue[Optional[_SessionRequest]] = asyncio.Queue() + self._closed = False + self._run_future: Optional[Future[None]] = None + + def attach_future(self, future: Future[None]) -> None: + """Attach the Future representing the worker's run() task. + + Args: + future: Future returned from submitting run() to the event loop + """ + self._run_future = future + + @property + def run_future(self) -> Optional[Future[None]]: + return self._run_future + + async def run(self) -> None: + """Main worker loop that processes requests using a persistent ClaudeSDKClient. + + Creates and manages the ClaudeSDKClient lifecycle, processes queued requests, + and handles cleanup on shutdown or error. + """ + logger.debug("Session worker starting for '%s'", self._session_id) + try: + async with ClaudeSDKClient(options=self._options) as client: + while True: + request = await self._request_queue.get() + if request is None: # Shutdown signal + break + await self._handle_request(client, request) + except Exception as exc: # pylint: disable=broad-except + logger.error("Session worker for '%s' encountered an error: %s", self._session_id, exc, exc_info=True) + await self._drain_pending_requests(exc) + finally: + self._closed = True + await self._drain_pending_requests() + logger.debug("Session worker stopped for '%s'", self._session_id) + + async def _handle_request(self, client: ClaudeSDKClient, request: _SessionRequest) -> None: + """Process a single request and stream responses back through the queue. + + Args: + client: The persistent ClaudeSDKClient instance + request: The request to process + """ + try: + await client.query(request.prompt, session_id=request.session_id) + async for message in client.receive_response(): + # Check if request was cancelled before sending message + if request.is_cancelled(): + logger.debug("Request cancelled for session '%s', stopping message stream", self._session_id) + break + request.response_queue.put(message) + except Exception as exc: # pylint: disable=broad-except + logger.error("Error handling request for session '%s': %s", self._session_id, exc, exc_info=True) + if not request.is_cancelled(): + request.response_queue.put(exc) + finally: + request.response_queue.put(_RESPONSE_SENTINEL) + + async def _drain_pending_requests(self, error: Optional[Exception] = None) -> None: + """Drain any pending requests from the queue, optionally sending an error. + + Called during worker shutdown to ensure all pending requests are completed. + + Args: + error: Optional exception to send to pending requests + """ + while True: + try: + pending = self._request_queue.get_nowait() + except asyncio.QueueEmpty: + break + if pending is None: + continue + if error is not None: + pending.response_queue.put(error) + pending.response_queue.put(_RESPONSE_SENTINEL) + + async def enqueue(self, request: _SessionRequest) -> None: + """Enqueue a request for processing by this worker. + + Args: + request: The request to enqueue + + Raises: + RuntimeError: If the worker has been closed + """ + if self._closed: + logger.error("Attempted to enqueue request to closed session worker") + raise RuntimeError("Session worker is closed") + await self._request_queue.put(request) + + async def close(self) -> None: + """Signal the worker to shut down gracefully. + + Sends a None sentinel to the queue, which will cause the worker's run() loop to exit. + """ + if self._closed: + return + await self._request_queue.put(None) + + +class SessionManager: + """Manages Claude SDK sessions with dedicated async workers per session. + + The SessionManager provides: + - Session lifecycle management: Creates, caches, and cleans up session workers + - Client reuse: Each session maintains a persistent ClaudeSDKClient for context continuity + - Options validation: Recreates clients when configuration changes + - TTL-based cleanup: Automatically removes idle sessions after a configurable timeout + + This enables efficient multi-turn conversations where Claude maintains context across + multiple queries within the same session. + """ + + def __init__(self, runtime: AsyncRuntime, config: Optional[SessionConfig] = None): + """Initialize the session manager. + + Args: + runtime: The AsyncRuntime providing the event loop for workers + config: Configuration for session behavior (default: SessionConfig()) + """ + self._runtime = runtime + self._config = config or SessionConfig() + self._session_ttl = self._config.ttl + self._sessions: Dict[str, _SessionState] = {} # session_id -> _SessionState + self._thread_lock = threading.Lock() # Protects _sessions dict access + self._is_closed = False + + async def stream_query( + self, + session_id: str, + options: ClaudeAgentOptions, + prompt: str, + ) -> AsyncGenerator[object, None]: + """Submit a query to a session and stream responses back. + + This is the main entry point for querying Claude. It: + 1. Ensures a worker exists for the session (creating if needed) + 2. Enqueues the request to the worker + 3. Streams response messages back as they arrive + + Args: + session_id: Unique identifier for the session + options: Configuration for the Claude client + prompt: The user's message/prompt + + Yields: + Response messages from Claude (AssistantMessage, StreamEvent, etc.) + + Raises: + RuntimeError: If the SessionManager is closed + Exception: Any exception raised by the Claude SDK + """ + if self._is_closed: + logger.error("Attempted to stream_query on closed SessionManager") + raise RuntimeError("SessionManager is closed") + + await self._cleanup_expired_sessions() + state = await self._get_or_create_state(session_id, options) + + now = time.time() + with self._thread_lock: + state.last_access = now + + response_queue: queue.Queue[object] = queue.Queue() + request = _SessionRequest(prompt=prompt, session_id=session_id, response_queue=response_queue) + + enqueue_future = self._runtime.submit_coroutine(state.worker.enqueue(request)) + await asyncio.wrap_future(enqueue_future) + + try: + while True: + message = await asyncio.to_thread(response_queue.get) + if message is _RESPONSE_SENTINEL: + break + if isinstance(message, Exception): + raise message + yield message + finally: + # Signal cancellation to the worker so it stops sending messages + request.cancel() + + # Drain any remaining messages from the queue to prevent memory leak + # The worker will stop sending after seeing the cancellation flag + while True: + try: + remaining = response_queue.get_nowait() + if remaining is _RESPONSE_SENTINEL: + break + except queue.Empty: + # Wait briefly for the sentinel, then give up + try: + remaining = response_queue.get(timeout=0.1) + if remaining is _RESPONSE_SENTINEL: + break + except queue.Empty: + break + + with self._thread_lock: + state.last_access = time.time() + logger.debug("Cleaned up stream_query for session '%s'", session_id) + + async def _get_or_create_state( + self, + session_id: str, + options: ClaudeAgentOptions, + ) -> _SessionState: + """Get existing session state or create a new one. + + If the session exists but options have changed, the old session is shut down + and a new one is created with the updated options. + + Args: + session_id: Unique identifier for the session + options: Configuration for the Claude client + + Returns: + The session state (existing or newly created) + """ + options_signature = _fingerprint_options(options) + to_close: Optional[_SessionState] = None + + with self._thread_lock: + existing = self._sessions.get(session_id) + if existing: + if existing.options_signature == options_signature: + return existing + # Options changed; replace the session. + to_close = existing + del self._sessions[session_id] + + if to_close is not None: + await self._shutdown_state(session_id, to_close) + + new_state = self._create_state(session_id, options, options_signature) + return new_state + + def _create_state( + self, + session_id: str, + options: ClaudeAgentOptions, + options_signature: str, + ) -> _SessionState: + """Create a new session state with a worker running in the AsyncRuntime. + + Args: + session_id: Unique identifier for the session + options: Configuration for the Claude client + options_signature: Fingerprint of options for validation + + Returns: + The newly created session state + """ + worker = _SessionWorker(session_id=session_id, options=options) + run_future = self._runtime.submit_coroutine(worker.run()) + worker.attach_future(run_future) + + state = _SessionState(worker=worker, options_signature=options_signature, last_access=time.time()) + with self._thread_lock: + self._sessions[session_id] = state + return state + + async def _shutdown_state(self, session_id: str, state: _SessionState) -> None: + """Shut down a session worker and wait for cleanup. + + Args: + session_id: Unique identifier for the session + state: The session state to shut down + """ + logger.info("Shutting down session '%s'", session_id) + try: + close_future = self._runtime.submit_coroutine(state.worker.close()) + await asyncio.wrap_future(close_future) + except Exception as exc: # pylint: disable=broad-except + logger.error("Error closing session '%s': %s", session_id, exc, exc_info=True) + + run_future = state.worker.run_future + if run_future is not None: + try: + await asyncio.wrap_future(run_future) + except Exception as exc: # pylint: disable=broad-except + logger.error("Session loop for '%s' raised during shutdown: %s", session_id, exc, exc_info=True) + + async def _cleanup_expired_sessions(self) -> None: + """Clean up sessions that have exceeded their TTL. + + Called before each query to remove idle sessions based on last_access time. + """ + if self._session_ttl <= 0: + return + + current_time = time.time() + expired: list[tuple[str, _SessionState]] = [] + + with self._thread_lock: + for session_id, state in list(self._sessions.items()): + if current_time - state.last_access > self._session_ttl: + expired.append((session_id, state)) + del self._sessions[session_id] + + for session_id, state in expired: + logger.info("Session '%s' expired after %ss of inactivity", session_id, self._session_ttl) + await self._shutdown_state(session_id, state) + + def close(self) -> None: + """Close the session manager and shut down all active sessions. + + This is a synchronous method that blocks until all workers are terminated. + Should be called during application shutdown. + """ + if self._is_closed: + return + + with self._thread_lock: + self._is_closed = True + sessions = list(self._sessions.items()) + self._sessions.clear() + + for session_id, state in sessions: + try: + close_future = self._runtime.submit_coroutine(state.worker.close()) + close_future.result() + except Exception as exc: # pylint: disable=broad-except + logger.error("Error closing session '%s': %s", session_id, exc, exc_info=True) + + run_future = state.worker.run_future + if run_future is not None: + try: + run_future.result() + except Exception as exc: # pylint: disable=broad-except + logger.error("Session loop for '%s' raised during manager close: %s", + session_id, + exc, + exc_info=True) diff --git a/trpc_agent_sdk/server/agents/claude/_setup.py b/trpc_agent_sdk/server/agents/claude/_setup.py new file mode 100644 index 000000000..745880609 --- /dev/null +++ b/trpc_agent_sdk/server/agents/claude/_setup.py @@ -0,0 +1,417 @@ +# 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. +# +"""Claude setup for TRPC Agent framework.""" + +import base64 +import multiprocessing +import os +import time +from typing import Awaitable +from typing import Callable +from typing import Dict +from typing import Optional +from typing import Union + +import cloudpickle as pickle +import requests +import uvicorn + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.models import LLMModel + +from ._proxy import AnthropicProxyApp + + +class _ServerState: + """Global server state.""" + + def __init__(self): + self.process: Optional[multiprocessing.Process] = None + self.host: str = "" + self.port: int = 0 + self.is_running: bool = False + + def reset(self) -> None: + """Reset all state variables to their default values.""" + self.process = None + self.host = "" + self.port = 0 + self.is_running = False + + +# Global server state instance +_state = _ServerState() + + +def _run_server_subprocess(host: str, port: int, serialized_models: Optional[bytes] = None): + """Run the uvicorn server in a proxy process. + + This function is the target for the proxy process. + + Args: + host: Host to bind to + port: Port to bind to + serialized_models: Pickled dictionary of default models (can be LLMModel instances or callable factories) + """ + claude_models = None + if serialized_models: + claude_models = pickle.loads(serialized_models) + + app = AnthropicProxyApp(claude_models=claude_models) + # set log_config=None to disable log + config = uvicorn.Config(app=app.app, host=host, port=port, log_config=None) + server = uvicorn.Server(config) + server.run() + + +def _wait_for_server_ready(host: str, port: int, timeout: float = 10.0) -> bool: + """Wait for the server to be ready by polling the health endpoint. + + Args: + host: Server host + port: Server port + timeout: Maximum time to wait in seconds + + Returns: + True if server is ready, False if timeout + """ + start_time = time.time() + url = f"http://{host}:{port}/" + + while time.time() - start_time < timeout: + try: + response = requests.get(url, timeout=1.0) + if response.status_code == 200: + logger.debug("Server is ready at %s", url) + return True + except requests.exceptions.RequestException: + # Server not ready yet + pass + + time.sleep(0.1) + + return False + + +def _get_server_url() -> str: + """Get the server URL. + + Returns: + Server URL (e.g., "http://0.0.0.0:8082") + + Raises: + RuntimeError: If server is not initialized + """ + if not _state.is_running: + raise RuntimeError("Server not initialized. Call setup_claude_env() first.") + return f"http://{_state.host}:{_state.port}" + + +def setup_claude_env( + proxy_host: str = "0.0.0.0", + proxy_port: int = 8082, + timeout: float = 10.0, + claude_models: Optional[Dict[str, Union[LLMModel, Callable[[], Awaitable[LLMModel]]]]] = None, +) -> None: + """Initialize and start the proxy server as a proxy process. + + This creates and starts the uvicorn server in a separate process, allowing it + to handle Anthropic API requests independently. The function waits for the + server to be ready before returning. + + Process lifecycle management: + - Creates a new proxy process when setup_claude_env() is called + - Waits for the server to be ready by polling the health endpoint + - Properly terminates the proxy process when destroy() is called + + Design: + - The AnthropicProxyApp class manages the FastAPI application and business logic + - This module manages the server lifecycle (starting, stopping, cleanup) + - Uvicorn server runs in a separate proxy process + + Args: + proxy_host: Host address to bind the server to (default: "0.0.0.0") + proxy_port: Port number to bind the server to (default: 8082) + timeout: Maximum time to wait for server startup in seconds (default: 10.0) + claude_models: Dictionary mapping model names to either: + - LLMModel instances (used directly) + - Callable factories returning Awaitable[LLMModel] (invoked when model is requested) + Special key "all" will be expanded to "sonnet", "opus", "haiku" + to support claude-code default model names. + Note: Callable factories require cloudpickle for serialization. + + Raises: + RuntimeError: If server is already initialized or fails to start + + Example: + >>> from trpc_agent_sdk.server.agents.claude import setup_claude_env, destroy_claude_env, add_model + >>> from trpc_agent_sdk.models.openai_model import OpenAIModel + >>> + >>> # Initialize with default models + >>> model = OpenAIModel(model_name="gpt-4", api_key="...") + >>> setup_claude_env( + ... proxy_host="0.0.0.0", + ... proxy_port=8082, + ... claude_models={"all": model} # Maps to sonnet, opus, haiku + ... ) + >>> + >>> # Or specify individual models + >>> setup_claude_env( + ... proxy_host="0.0.0.0", + ... proxy_port=8082, + ... claude_models={ + ... "sonnet": model_sonnet, + ... "opus": model_opus, + ... "haiku": model_haiku, + ... } + ... ) + >>> + >>> # Or use callable factories for lazy model creation + >>> async def create_model(): + ... return OpenAIModel(model_name="gpt-4", api_key="...") + >>> setup_claude_env( + ... proxy_host="0.0.0.0", + ... proxy_port=8082, + ... claude_models={"all": create_model} # Factory will be called when model is needed + ... ) + >>> + >>> # ... use the server ... + >>> destroy_claude_env() # Properly terminate proxy process + """ + if _state.process is not None: + raise RuntimeError("Server already initialized. Call destroy_claude_env() first.") + + logger.debug("Initializing proxy server on %s:%s", proxy_host, proxy_port) + + # Process claude_models + processed_models = None + if claude_models: + processed_models = {} + + # Check if "all" key exists + if "all" in claude_models: + all_model = claude_models["all"] + logger.debug("Expanding 'all' model to sonnet, opus, haiku: %s", all_model) + processed_models["sonnet"] = all_model + processed_models["opus"] = all_model + processed_models["haiku"] = all_model + + # Add any other keys (but skip "all") + for key, model in claude_models.items(): + if key != "all": + processed_models[key] = model + else: + # No "all" key, just copy the dict + processed_models = claude_models.copy() + + # Store host and port + _state.host = proxy_host + _state.port = proxy_port + + # Serialize models if present + serialized_models = None + if processed_models: + serialized_models = pickle.dumps(processed_models) + + # Create and start the proxy process + _state.process = multiprocessing.Process( + target=_run_server_subprocess, + args=(proxy_host, proxy_port, serialized_models), + name="AnthropicProxyServer", + daemon=False, + ) + + _state.process.start() + logger.info("Proxy server proxy process started (PID: %s)", _state.process.pid) + + # Wait for server to be ready + logger.debug("Waiting for server to be ready...") + if not _wait_for_server_ready(proxy_host, proxy_port, timeout=timeout): + # Server failed to start, clean up + logger.error("Server failed to start within timeout") + _state.process.terminate() + _state.process.join(timeout=5.0) + if _state.process.is_alive(): + logger.warning("Force killing server process...") + _state.process.kill() + _state.process.join() + _state.reset() + raise RuntimeError(f"Server failed to start within {timeout} seconds") + + _state.is_running = True + logger.info("Proxy server is ready at http://%s:%s", proxy_host, proxy_port) + + # Set environment variables for Anthropic client + server_url = f"http://{proxy_host}:{proxy_port}" + os.environ["ANTHROPIC_BASE_URL"] = server_url + os.environ["ANTHROPIC_AUTH_TOKEN"] = "xxxx" + + +def destroy_claude_env() -> None: + """Stop and destroy the proxy server proxy process. + + This properly shuts down the running server proxy process and cleans up all resources. + Any added models in the proxy process will be cleared. + + The function ensures proper proxy process lifecycle management: + - Terminates the proxy process gracefully + - Waits for the proxy process to exit (with timeout) + - Forces kill if proxy process doesn't exit within timeout + - Cleans up all references + + It's safe to call this function multiple times - if the server is not running, + it will log a warning and return without error. + + Example: + >>> from trpc_agent_sdk.server.agents.claude import setup_claude_env, destroy_claude_env + >>> setup_claude_env() + >>> # ... use the server ... + >>> destroy_claude_env() # Properly terminate proxy process and clean up + """ + if _state.process is None: + logger.warning("Server not initialized, nothing to destroy.") + return + + _state.is_running = False + + if _state.process.is_alive(): + logger.info("Terminating proxy process (PID: %s)...", _state.process.pid) + _state.process.terminate() + + # Wait for process to complete (with timeout) + _state.process.join(timeout=5.0) + + if _state.process.is_alive(): + logger.warning("Subprocess did not stop within timeout, force killing...") + _state.process.kill() + _state.process.join() + logger.info("Subprocess killed successfully.") + else: + logger.info("Subprocess terminated successfully.") + else: + logger.info("Proxy process already stopped.") + + _state.reset() + + +def _add_model(model: LLMModel, generate_content_config: Optional[any] = None) -> str: + """Add a model to the proxy server via HTTP. + + This function serializes the model and optional generate_content_config using pickle + and sends it to the /add_model endpoint in the proxy process. + + Args: + model: The LLMModel instance to add + generate_content_config: Optional GenerateContentConfig to associate with the model + + Returns: + Model key that can be used to reference this model + + Raises: + RuntimeError: If server is not initialized + requests.exceptions.RequestException: If the HTTP request fails + + Example: + >>> from trpc_agent_sdk.server.agents.claude import setup_claude_env, _add_model + >>> from trpc_agent_sdk.models.openai_model import OpenAIModel + >>> from trpc_agent_sdk.types import GenerateContentConfig + >>> + >>> setup_claude_env() + >>> model = OpenAIModel(model_name="gpt-4", api_key="...") + >>> config = GenerateContentConfig(temperature=0.7, max_output_tokens=1000) + >>> model_key = _add_model(model, config) + >>> print(f"Model added with key: {model_key}") + """ + if not _state.is_running: + raise RuntimeError("Server not initialized. Call setup_claude_env() first.") + + # Pickle the model + pickled_model = pickle.dumps(model) + # Encode as base64 for JSON transport + encoded_model = base64.b64encode(pickled_model).decode("ascii") + + # Prepare request data + request_data = {"model_data": encoded_model} + + # If generate_content_config is provided, pickle and encode it too + if generate_content_config: + pickled_config = pickle.dumps(generate_content_config) + encoded_config = base64.b64encode(pickled_config).decode("ascii") + request_data["config_data"] = encoded_config + + # Send to server + url = f"{_get_server_url()}/add_model" + response = requests.post( + url, + json=request_data, + timeout=10.0, + ) + + response.raise_for_status() + result = response.json() + + model_key = result.get("model") + if not model_key: + raise RuntimeError("Server did not return a model key") + + return model_key + + +def _delete_model(model_key: str) -> bool: + """Delete a model from the proxy server via HTTP. + + This function sends a request to the /delete_model endpoint in the proxy process + to remove a previously added model. + + Args: + model_key: The model key returned from _add_model + + Returns: + True if the model was successfully deleted, False otherwise + + Raises: + RuntimeError: If server is not initialized + requests.exceptions.RequestException: If the HTTP request fails + + Example: + >>> from trpc_agent_sdk.server.agents.claude import setup_claude_env, _add_model, _delete_model + >>> from trpc_agent_sdk.models.openai_model import OpenAIModel + >>> + >>> setup_claude_env() + >>> model = OpenAIModel(model_name="gpt-4", api_key="...") + >>> model_key = _add_model(model) + >>> print(f"Model added with key: {model_key}") + >>> # ... use the model ... + >>> success = _delete_model(model_key) + >>> print(f"Model deleted: {success}") + """ + if not _state.is_running: + raise RuntimeError("Server not initialized. Call setup_claude_env() first.") + + # Prepare request data + request_data = {"model_key": model_key} + + # Send to server + url = f"{_get_server_url()}/delete_model" + response = requests.post( + url, + json=request_data, + timeout=10.0, + ) + + response.raise_for_status() + result = response.json() + + success = result.get("success", False) + message = result.get("message", "") + + if success: + logger.debug("Model '%s' deleted successfully: %s", model_key, message) + else: + logger.warning("Failed to delete model '%s': %s", model_key, message) + + return success diff --git a/trpc_agent_sdk/server/knowledge/__init__.py b/trpc_agent_sdk/server/knowledge/__init__.py new file mode 100644 index 000000000..97543eda1 --- /dev/null +++ b/trpc_agent_sdk/server/knowledge/__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 Apache-2.0. +""" +Since there may be multiple services in this directory, we do not import them here. +Users should explicitly import the specific classes they need from the corresponding files to avoid unnecessary imports. +""" diff --git a/trpc_agent_sdk/server/knowledge/langchain_knowledge.py b/trpc_agent_sdk/server/knowledge/langchain_knowledge.py new file mode 100644 index 000000000..c75be5108 --- /dev/null +++ b/trpc_agent_sdk/server/knowledge/langchain_knowledge.py @@ -0,0 +1,313 @@ +# 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. +"""This module integrates with the Langchain ecosystem, providing a default implementation of RAG.""" +from enum import Enum +from typing import Any +from typing import Dict +from typing import List +from typing_extensions import override + +from pydantic import BaseModel + +from langchain_core.document_loaders import BaseLoader +from langchain_core.documents import BaseDocumentTransformer +from langchain_core.documents import Document +from langchain_core.embeddings import Embeddings +from langchain_core.messages import BaseMessage +from langchain_core.prompt_values import PromptValue +from langchain_core.prompts.base import BasePromptTemplate +from langchain_core.retrievers import BaseRetriever +from langchain_core.runnables import RunnableConfig +from langchain_core.vectorstores import VectorStore + +# Version compatibility: Support both LangChain 0.3.x and 1.x.x +# In LangChain 1.x, Chain is deprecated in favor of Runnable +try: + from langchain_core.runnables import Runnable as Chain +except ImportError: + # Fallback to Chain for LangChain 0.3.x + from langchain.chains.base import Chain + +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.knowledge import KnowledgeBase +from trpc_agent_sdk.knowledge import SearchDocument +from trpc_agent_sdk.knowledge import SearchRequest +from trpc_agent_sdk.knowledge import SearchResult +from trpc_agent_sdk.log import logger + + +# SearchType vector retrieval type +class SearchType(Enum): + SIMILARITY_SCORE_THRESHOLD = "similarity_score_threshold" + SIMILARITY = "similarity" + MAX_MARGINAL_RELEVANCE = "mmr" + + +# Return relevant documents format type +class ListType(Enum): + TYPE_LIST_DOCUMENT = 0 + TYPE_LIST_DOCUMENT_SCORE = 1 + + +class LangchainParams(BaseModel): + """Langchain chain kwargs parameters, user can define""" + # TODO: add Langchain chain kwargs parameters + + +class LangchainKnowledge(KnowledgeBase): + + def __init__(self, + chain: Chain = None, + prompt_template: BasePromptTemplate = None, + document_loader: BaseLoader = None, + document_transformer: BaseDocumentTransformer = None, + embedder: Embeddings = None, + vectorstore: VectorStore = None, + retriever: BaseRetriever = None): + """Implement the default logic for RAG, integrate with tRPC-Agent framework, support Langchain ecosystem + + :params: + chain: complete Langchain chain; if available, it can be called directly + and other configurations are ignored + prompt_template: Langchain Prompt template + document_loader: Langchain document loader + document_transformer: Langchain document transformer + embedder: Langchain vector embedding model + vectorstore: Langchain vector database + retriever: Langchain retriever + """ + # Initialize embedder etc. components. If chain is set, other + # configurations are ignored and the chain is called directly. + self.chain = chain + self.prompt_template = prompt_template + self.document_loader = document_loader + # document_transformer if None, default is not split documents + self.document_transformer = document_transformer + self.embedder = embedder + self.vectorstore = vectorstore + self.retriever = retriever + self.common_runnable_config = None + + def _check_chain_component(self): + """Check the necessary components for RAG.""" + if self.vectorstore is None and self.retriever is None: + raise TypeError("vectorstore and retriever is None, " + "vectorstore or retriever should be initialized.") + + def _parse_agent_config(self, ctx: AgentContext) -> RunnableConfig: + """Parse agent configuration and return stream mode and runnable config. + + :param ctx: agent context + :return: runnable config + """ + runnable_config = ctx.get_metadata("runnable_config") + + return runnable_config + + def _check_list_type(self, relevant_documents) -> bool: + """Check if the returned relevant documents contain relevance scores""" + if len(relevant_documents) == 0: + return ListType.TYPE_LIST_DOCUMENT.value + if isinstance(relevant_documents, list) and isinstance(relevant_documents[0], tuple): + return ListType.TYPE_LIST_DOCUMENT_SCORE.value + return ListType.TYPE_LIST_DOCUMENT.value + + def _tran_result(self, relevant_documents) -> SearchResult: + """Convert the relevant documents retrieved to SearchResult""" + search_results: SearchResult = SearchResult() + + is_contains_score: bool = (self._check_list_type(relevant_documents) == ListType.TYPE_LIST_DOCUMENT_SCORE.value) + + for doc in relevant_documents: + search_document: SearchDocument = SearchDocument() + if is_contains_score: + search_document.document = doc[0] + search_document.score = doc[1] + else: + search_document.document = doc + search_results.documents.append(search_document) + + return search_results + + def _get_history_message(self, ctx: AgentContext, req: SearchRequest) -> str: + """Get the history conversation context""" + history: List[BaseMessage] = req.history + user_id: str = req.user_id + session_id: str = req.session_id + assistant_name: str = ctx.get_metadata("assistant_name") + + context: str = "" + context += f"user_id: {user_id}\n" + if assistant_name: + context += f"assistant: {assistant_name}\n" + context += f"session_id: {session_id}\n" + context += "content: " + for msg in history: + context += msg.text() + + return context + + def gen_langchain_extra_params(self, langchain_params: LangchainParams) -> Dict: + """Generate Langchain extra parameters + + :param langchain_params: Langchain parameters + :return: Langchain extra parameters + """ + return {"langchain": langchain_params.model_dump()} + + async def _run_chain(self, context: str, req: SearchRequest, kwargs): + """Run the complete Langchain chain directly""" + if req.query.text: + query = req.query.text + else: + raise ValueError("query should be text, but got None") + + chain_runnable_config = None + if self.common_runnable_config: + chain_runnable_config = self.common_runnable_config.copy() + + if 'chain_runnable_config' in req.params.extra_params: + chain_runnable_config = req.params.extra_params.get('chain_runnable_config') + + relevant_documents = await self.chain.ainvoke({ + "context": context, + "query": query + }, chain_runnable_config, **kwargs) + + return relevant_documents + + async def _run_vectorstore_retrieve(self, req: SearchRequest, query: str, langchain_kwargs): + """Use the vector database for retrieval""" + search_type: str = req.params.search_type + if search_type == SearchType.SIMILARITY_SCORE_THRESHOLD.value: + # query with Score + return await self.vectorstore.asimilarity_search_with_relevance_scores(query=query, + k=req.params.rank_top_k, + **langchain_kwargs) + # query without Score + return await self.vectorstore.asearch( + query=query, + search_type=search_type, + k=req.params.rank_top_k, + **langchain_kwargs, + ) + + async def _run_retriever_retrieve(self, req: SearchRequest, query: str, langchain_kwargs): + """Use the retriever for retrieval""" + retriever_runnable_config = None + if self.common_runnable_config: + retriever_runnable_config = self.common_runnable_config.copy() + + if 'retriever_runnable_config' in req.params.extra_params: + retriever_runnable_config: RunnableConfig = req.params.extra_params.get('retriever_runnable_config') + + relevant_documents: List[Document] = await self.retriever.ainvoke(input=query, + config=retriever_runnable_config, + **langchain_kwargs) + + return relevant_documents + + async def _run_vectorstore_retriever_retrieve(self, req: SearchRequest, query: str, langchain_kwargs): + """Use the vector database for retrieval, and use the retriever for reranking""" + relevant_documents = await self._run_vectorstore_retrieve(req=req, + query=query, + langchain_kwargs=langchain_kwargs) + + retriever = self.retriever.from_documents(relevant_documents) + + retriever_runnable_config = None + if self.common_runnable_config: + retriever_runnable_config = self.common_runnable_config.copy() + if 'retriever_runnable_config' in req.params.extra_params: + retriever_runnable_config: RunnableConfig = req.params.extra_params.get('retriever_runnable_config') + + relevant_documents: List[Document] = await retriever.ainvoke(input=query, + config=retriever_runnable_config, + **langchain_kwargs) + + return relevant_documents + + @override + async def search(self, ctx: AgentContext, req: SearchRequest) -> SearchResult: + """Implement the default logic for RAG""" + self.common_runnable_config: RunnableConfig = self._parse_agent_config(ctx) + + # Get the previous conversation context + context = self._get_history_message(ctx=ctx, req=req) + + # Extra parameters + langchain_kwargs = LangchainParams().model_dump() + langchain_kwargs.update(req.params.extra_params.get('langchain', {})) + + if self.chain: + # If a complete Langchain chain is already available, call the chain directly + relevant_documents = await self._run_chain(context=context, req=req, kwargs=langchain_kwargs) + return self._tran_result(relevant_documents) + + # If there is no complete Langchain chain, check the necessary components for RAG + self._check_chain_component() + + # Build the Prompt + if req.query.text: + query = req.query.text + else: + raise ValueError("query should be text, but got None") + + if self.prompt_template: + query_runnable_config = None + if self.common_runnable_config: + query_runnable_config = self.common_runnable_config.copy() + if 'query_runnable_config' in req.params.extra_params: + query_runnable_config = req.params.extra_params.get('query_runnable_config') + + query: PromptValue = await self.prompt_template.ainvoke({ + "context": context, + "query": query + }, query_runnable_config, **langchain_kwargs) + query: str = query.to_string() + + # If the retriever is not specified, use the vector database for retrieval + if self.vectorstore and self.retriever is None: + relevant_documents = await self._run_vectorstore_retrieve(req=req, + query=query, + langchain_kwargs=langchain_kwargs) + elif self.vectorstore is None and self.retriever: + # If the vector database is not specified, use the retriever for retrieval + relevant_documents = await self._run_retriever_retrieve(req=req, + query=query, + langchain_kwargs=langchain_kwargs) + elif self.vectorstore and self.retriever: + # If both are specified, use the vector database for retrieval, and use the retriever for reranking + relevant_documents = await self._run_vectorstore_retriever_retrieve(req=req, + query=query, + langchain_kwargs=langchain_kwargs) + + # Adapt to the tRPC-Agent framework SearchResult + search_results: SearchResult = self._tran_result(relevant_documents=relevant_documents) + + return search_results + + async def create_vectorstore_from_document( + self, + **kwargs: Any, + ) -> None: + """Create the vector database from documents""" + # Read the documents + documents = [] + if self.document_loader: + documents = await self.document_loader.aload() + else: + logger.info("document loader is None") + + # Split the documents + if self.document_transformer: + documents = await self.document_transformer.atransform_documents(documents, **kwargs) + else: + logger.info("document transformer is None") + + # Build the vector database + vector_db = await self.vectorstore.afrom_documents(documents, embedding=self.embedder, **kwargs) + self.vectorstore = vector_db diff --git a/trpc_agent_sdk/server/knowledge/tools/__init__.py b/trpc_agent_sdk/server/knowledge/tools/__init__.py new file mode 100644 index 000000000..1e74e3e4f --- /dev/null +++ b/trpc_agent_sdk/server/knowledge/tools/__init__.py @@ -0,0 +1,14 @@ +# 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. +"""Knowledge tools module for TRPC Agent framework.""" + +from .langchain_knowledge_searchtool import AgenticLangchainKnowledgeSearchTool +from .langchain_knowledge_searchtool import LangchainKnowledgeSearchTool + +__all__ = [ + "AgenticLangchainKnowledgeSearchTool", + "LangchainKnowledgeSearchTool", +] diff --git a/trpc_agent_sdk/server/knowledge/tools/langchain_knowledge_searchtool.py b/trpc_agent_sdk/server/knowledge/tools/langchain_knowledge_searchtool.py new file mode 100644 index 000000000..53dacabf0 --- /dev/null +++ b/trpc_agent_sdk/server/knowledge/tools/langchain_knowledge_searchtool.py @@ -0,0 +1,208 @@ +# 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. +"""Langchain knowledge search tool for TRPC Agent framework.""" + +from typing import Any +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.knowledge import KnowledgeFilterExpr +from trpc_agent_sdk.knowledge import SearchParams +from trpc_agent_sdk.knowledge import SearchRequest +from trpc_agent_sdk.knowledge import SearchResult +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Part +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ..langchain_knowledge import LangchainKnowledge +from ..langchain_knowledge import SearchType + + +class LangchainKnowledgeSearchTool(BaseTool): + """Knowledge search tool with optional static knowledge filter.""" + + def __init__( + self, + rag: LangchainKnowledge, + top_k: int = 3, + search_type: SearchType = SearchType.SIMILARITY, + filters_name: Optional[list[str]] = None, + filters: Optional[list[BaseFilter]] = None, + *, + min_score: float = 0.0, + knowledge_filter: KnowledgeFilterExpr | None = None, + ): + name = "knowledge_search" + description = "Search for relevant information in the knowledge base" + super().__init__( + name=name, + description=description, + filters_name=filters_name, + filters=filters, + ) + self.rag = rag + self.top_k = top_k + self.min_score = min_score + self.search_type = search_type + self.knowledge_filter = knowledge_filter + + @override + def _get_declaration(self) -> Optional[FunctionDeclaration]: + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties={ + "query": Schema( + type=Type.STRING, + description="The search query to find relevant documents", + ), + }, + required=["query"], + ), + ) + + def _build_search_params(self, knowledge_filter: KnowledgeFilterExpr | None) -> SearchParams: + extra_params = self.rag.build_search_extra_params(knowledge_filter) + return SearchParams( + rank_top_k=self.top_k, + search_type=self.search_type, + extra_params=extra_params, + ) + + def _serialize_documents(self, search_result: SearchResult) -> list[dict[str, Any]]: + serializable_documents: list[dict[str, Any]] = [] + for doc in search_result.documents: + score_raw = doc.score + score = float(score_raw) if isinstance(score_raw, (int, float)) else 0.0 + if score < self.min_score: + continue + serializable_doc = { + "document": { + "page_content": doc.document.page_content, + "metadata": doc.document.metadata, + }, + "score": score, + } + serializable_documents.append(serializable_doc) + return serializable_documents + + async def _search_with_knowledge_filter( + self, + *, + tool_context: InvocationContext, + query: str, + knowledge_filter: KnowledgeFilterExpr | None, + ) -> list[dict[str, Any]]: + agent_context = tool_context.agent_context + if agent_context is None: + agent_context = new_agent_context(timeout=3000) + + search_params = self._build_search_params(knowledge_filter) + search_request = SearchRequest(params=search_params) + search_request.query = Part.from_text(text=query) + search_result: SearchResult = await self.rag.search(agent_context, search_request) + if len(search_result.documents) == 0: + return [] + return self._serialize_documents(search_result) + + async def _search(self, *, tool_context: InvocationContext, query: str) -> list[dict[str, Any]]: + return await self._search_with_knowledge_filter( + tool_context=tool_context, + query=query, + knowledge_filter=self.knowledge_filter, + ) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + return await self._search( + tool_context=tool_context, + query=args["query"], + ) + + +class AgenticLangchainKnowledgeSearchTool(LangchainKnowledgeSearchTool): + """Knowledge search tool with static and LLM-generated dynamic filter support.""" + + def __init__( + self, + rag: LangchainKnowledge, + top_k: int = 3, + search_type: SearchType = SearchType.SIMILARITY, + filters_name: Optional[list[str]] = None, + filters: Optional[list[BaseFilter]] = None, + *, + min_score: float = 0.0, + knowledge_filter: KnowledgeFilterExpr | None = None, + ): + super().__init__( + rag=rag, + top_k=top_k, + search_type=search_type, + filters_name=filters_name, + filters=filters, + min_score=min_score, + knowledge_filter=knowledge_filter, + ) + self.description = "Search knowledge with an optional dynamic_filter expression" + + @override + def _get_declaration(self) -> Optional[FunctionDeclaration]: + dynamic_filter_description = ( + "dynamic_filter: KnowledgeFilterExpr object.\n" + "Fields:\n" + "- field: metadata field path, e.g. metadata.category\n" + "- operator: eq, ne, gt, gte, lt, lte, in, not in, like, " + "not like, between, and, or\n" + "- value: comparison value, or an array of sub-conditions for and/or\n" + "Examples:\n" + "1) {\"field\":\"metadata.category\",\"operator\":\"eq\",\"value\":\"machine-learning\"}\n" + "2) {\"operator\":\"and\",\"value\":[{\"field\":\"metadata.status\"," + "\"operator\":\"eq\",\"value\":\"active\"}]}") + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties={ + "query": Schema( + type=Type.STRING, + description="The search query to find relevant documents", + ), + "dynamic_filter": Schema( + type=Type.OBJECT, + description=dynamic_filter_description, + ), + }, + required=["query"], + ), + ) + + @override + async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any: + dynamic_filter = args.get("dynamic_filter") + if dynamic_filter is None: + final_filter = self.knowledge_filter + else: + parsed_filter = KnowledgeFilterExpr.model_validate(dynamic_filter) + if self.knowledge_filter is None: + final_filter = parsed_filter + else: + final_filter = KnowledgeFilterExpr( + operator="and", + value=[self.knowledge_filter, parsed_filter], + ) + return await self._search_with_knowledge_filter( + tool_context=tool_context, + query=args["query"], + knowledge_filter=final_filter, + ) diff --git a/trpc_agent_sdk/server/langfuse/__init__.py b/trpc_agent_sdk/server/langfuse/__init__.py new file mode 100644 index 000000000..9d277587a --- /dev/null +++ b/trpc_agent_sdk/server/langfuse/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Langfuse module for TRPC Agent framework.""" diff --git a/trpc_agent_sdk/server/langfuse/prompt/__init__.py b/trpc_agent_sdk/server/langfuse/prompt/__init__.py new file mode 100644 index 000000000..6d2f6cb72 --- /dev/null +++ b/trpc_agent_sdk/server/langfuse/prompt/__init__.py @@ -0,0 +1,14 @@ +# 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. +"""Langfuse prompt module for TRPC Agent framework.""" + +from ._manager import Instruction +from ._manager import RemoteInstructionManager + +__all__ = [ + "Instruction", + "RemoteInstructionManager", +] diff --git a/trpc_agent_sdk/server/langfuse/prompt/_manager.py b/trpc_agent_sdk/server/langfuse/prompt/_manager.py new file mode 100644 index 000000000..d005e1175 --- /dev/null +++ b/trpc_agent_sdk/server/langfuse/prompt/_manager.py @@ -0,0 +1,97 @@ +# 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. +"""Langfuse Remote Instruction Manager for tRPC-Agent. + +Fetches instructions via Langfuse REST API and injects version info into OTel traces. +Zero SDK dependency — uses only HTTP requests and OTel span attributes. + +API Reference: https://api.reference.langfuse.com/#tag/prompts/GET/api/public/v2/prompts/{promptName} +""" + +from typing import Any +from typing import Dict +from typing import Optional +from urllib.parse import quote + +import requests + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Instruction +from trpc_agent_sdk.types import InstructionMetadata + + +class RemoteInstructionManager: + """Fetches instructions from Langfuse REST API. + + Uses ``GET /api/public/v2/prompts/{promptName}`` with Basic Auth. + """ + + def __init__( + self, + public_key: str, + secret_key: str, + host: str, + ): + self._host = host.rstrip("/") + self._auth = (public_key, secret_key) + logger.info("RemoteInstructionManager initialized (host=%s)", self._host) + + def get_instruction( + self, + name: str, + version: Optional[int] = None, + label: Optional[str] = None, + ) -> Instruction: + """Fetch an instruction from Langfuse. + + Instruction-to-generation trace association is automatic when + the returned ``Instruction`` is passed as ``LlmAgent.instruction``. + + Args: + name: Instruction name in Langfuse. + version: Specific version number. + label: Specific label such as ``"production"`` or ``"staging"``. + Defaults to ``"production"`` on the server side when neither + *version* nor *label* is provided. + + Returns: + Instruction with template content and version metadata. + """ + url = f"{self._host}/api/public/v2/prompts/{quote(name, safe='')}" + params: Dict[str, Any] = {} + if version is not None: + params["version"] = version + if label is not None: + params["label"] = label + + try: + resp = requests.get(url, params=params, auth=self._auth, timeout=10) + resp.raise_for_status() + except Exception as e: + logger.error("Failed to fetch instruction '%s' from Langfuse: %s", name, e) + raise + + data = resp.json() + logger.debug("Fetched instruction '%s' from Langfuse: %s", name, data) + metadata = InstructionMetadata( + name=data["name"], + version=data["version"], + type=data.get("type", "text"), + labels=data.get("labels", []), + config=data.get("config", {}), + ) + result = Instruction( + instruction=data["prompt"], + metadata=metadata, + ) + + logger.info( + "Fetched instruction '%s' v%d (labels=%s)", + metadata.name, + metadata.version, + metadata.labels, + ) + return result diff --git a/trpc_agent_sdk/server/langfuse/tracing/__init__.py b/trpc_agent_sdk/server/langfuse/tracing/__init__.py new file mode 100644 index 000000000..4d6697a23 --- /dev/null +++ b/trpc_agent_sdk/server/langfuse/tracing/__init__.py @@ -0,0 +1,6 @@ +# 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. +"""Langfuse tracing module for TRPC Agent framework.""" diff --git a/trpc_agent_sdk/server/langfuse/tracing/opentelemetry.py b/trpc_agent_sdk/server/langfuse/tracing/opentelemetry.py new file mode 100644 index 000000000..5dab42d47 --- /dev/null +++ b/trpc_agent_sdk/server/langfuse/tracing/opentelemetry.py @@ -0,0 +1,657 @@ +# 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. +"""Langfuse OpenTelemetry tracing for TRPC Agent framework.""" + +import base64 +import json +import os +from dataclasses import dataclass +from typing import Any +from typing import Dict +from typing import Optional + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export import SpanExportResult +from opentelemetry.sdk.trace.export import SpanExporter + +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.telemetry import get_trpc_agent_span_name +from trpc_agent_sdk.tools import AGENT_TOOL_APP_NAME_SUFFIX + + +@dataclass +class LangfuseConfig: + """Configuration for Langfuse tracing.""" + + public_key: Optional[str] = None + secret_key: Optional[str] = None + host: Optional[str] = None + batch_export: bool = True + compatibility_old_version: bool = False + """Older version of Langfuse has different span attributes. So they should be setted differently.""" + enable_a2a_trace: bool = False + """Whether to enable a2a-sdk and HTTP instrumentation traces. Default is False (filter them out).""" + + +# Global Langfuse configuration, no need to pass as a constructor parameter of _LangfuseMixin +# which will mass up the constructor of SpanProcessor +_langfuse_config: Optional[LangfuseConfig] = None # pylint: disable=invalid-name + + +class _LangfuseMixin: + """Mixin class that provides Langfuse attribute mapping functionality.""" + + def _should_skip_span(self, span: ReadableSpan) -> bool: + """Check if a span should be skipped (not exported to Langfuse). + + Args: + span: The span to check. + + Returns: + True if the span should be skipped, False otherwise. + """ + # If enable_a2a_trace is True, don't filter out any spans + if _langfuse_config.enable_a2a_trace: + return False + + if not span.instrumentation_scope: + return False + + scope_name = span.instrumentation_scope.name + span_name = span.name + + # Filter out a2a-sdk traces + if scope_name == 'a2a-python-sdk': + logger.debug("Skipping a2a-sdk span: %s", span_name) + return True + + # Filter out OpenTelemetry auto-instrumentation traces + instrumentation_prefixes = [ + 'opentelemetry.instrumentation.httpx', + 'opentelemetry.instrumentation.urllib3', + 'opentelemetry.instrumentation.requests', + 'opentelemetry.instrumentation.asgi', + 'opentelemetry.instrumentation.fastapi', + ] + + for prefix in instrumentation_prefixes: + if scope_name.startswith(prefix): + logger.debug("Skipping OpenTelemetry instrumentation span: %s (scope: %s)", span_name, scope_name) + return True + + # Filter out HTTP method spans by name + http_method_prefixes = ['HTTP ', 'GET ', 'POST ', 'PUT ', 'DELETE ', 'PATCH ', 'HEAD ', 'OPTIONS '] + for prefix in http_method_prefixes: + if span_name.startswith(prefix): + logger.debug("Skipping HTTP method span: %s", span_name) + return True + + return False + + def _transform_span_for_langfuse(self, span: ReadableSpan) -> ReadableSpan: + """Transform TRPC agent span attributes to Langfuse format.""" + trpc_span_name = get_trpc_agent_span_name() + if span.name == "invocation": + span_name = span.attributes.get(f"{trpc_span_name}.runner.name", "unknown") + else: + span_name = span.name + + if _langfuse_config.compatibility_old_version: + langfuse_attributes = self._map_attributes_to_old_langfuse(span.attributes) + else: + langfuse_attributes = self._map_attributes_to_langfuse(span.attributes) + + # Fix for nested runner spans: Remove langfuse.trace.name if this is a trpc-agent tool span + # to avoid overwriting the root trace name (Langfuse only records the first trace.name) + # AgentTool spans are identified by the AGENT_TOOL_APP_NAME_SUFFIX in the runner.app_name attribute + app_name = span.attributes.get(f"{trpc_span_name}.runner.app_name", "") + if app_name and AGENT_TOOL_APP_NAME_SUFFIX in app_name: + # Remove trace-level attributes to let the root span define the trace + langfuse_attributes.pop("langfuse.trace.name", None) + logger.debug("Removed langfuse.trace.name from AgentTool span: %s (app_name: %s)", span_name, app_name) + + transformed_span = ReadableSpan( + name=span_name, + context=span.get_span_context(), + parent=span.parent, + resource=span.resource, + attributes=langfuse_attributes, + events=span.events, + links=span.links, + kind=span.kind, + status=span.status, + start_time=span.start_time, + end_time=span.end_time, + instrumentation_scope=span.instrumentation_scope, + ) + return transformed_span + + def _map_attributes_to_langfuse(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map TRPC agent attributes to Langfuse-compatible attributes.""" + langfuse_attributes = {} + + # Get the operation name to determine the span type + operation_name = attributes.get("gen_ai.operation.name", "") + + # Map based on operation type + if operation_name == "run_runner" or operation_name == "run_runner_cancelled": + # This is a trace-level span (runner execution or cancelled runner) + langfuse_attributes.update(self._map_trace_level_attributes(attributes)) + elif operation_name == "run_agent": + # This is a span observation (agent execution) + langfuse_attributes.update(self._map_agent_observation_attributes(attributes)) + elif operation_name == "call_llm": + # This is a generation observation (LLM call) + langfuse_attributes.update(self._map_generation_attributes(attributes)) + elif operation_name == "execute_tool": + # This is a span observation (tool execution) + langfuse_attributes.update(self._map_tool_observation_attributes(attributes)) + else: + # Default to span observation for unknown operations + langfuse_attributes.update(self._map_span_observation_attributes(attributes)) + + return langfuse_attributes + + def _map_trace_level_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map trace-level attributes for runner execution spans.""" + trace_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Map trace name from runner name + trace_attrs["langfuse.trace.name"] = attributes.get(f"{trpc_span_name}.runner.name", "unknown") + + # Map user ID from runner attributes + if f"{trpc_span_name}.runner.user_id" in attributes: + trace_attrs["langfuse.user.id"] = attributes[f"{trpc_span_name}.runner.user_id"] + + # Map session ID from runner attributes + if f"{trpc_span_name}.runner.session_id" in attributes: + trace_attrs["langfuse.session.id"] = attributes[f"{trpc_span_name}.runner.session_id"] + + trace_attrs["langfuse.observation.type"] = "span" + # Map input/output from runner attributes + if f"{trpc_span_name}.runner.input" in attributes: + trace_attrs["langfuse.trace.input"] = attributes[f"{trpc_span_name}.runner.input"] + trace_attrs["langfuse.observation.input"] = trace_attrs["langfuse.trace.input"] + + if f"{trpc_span_name}.runner.output" in attributes: + trace_attrs["langfuse.trace.output"] = attributes[f"{trpc_span_name}.runner.output"] + trace_attrs["langfuse.observation.output"] = trace_attrs["langfuse.trace.output"] + + # Map trace metadata from runner attributes + trace_metadata = {} + + # Map state.begin, state.end, and state.partial to metadata + if f"{trpc_span_name}.state.begin" in attributes: + trace_metadata["state_begin"] = attributes[f"{trpc_span_name}.state.begin"] + + if f"{trpc_span_name}.state.end" in attributes: + trace_metadata["state_end"] = attributes[f"{trpc_span_name}.state.end"] + + if f"{trpc_span_name}.state.partial" in attributes: + trace_metadata["state_partial"] = attributes[f"{trpc_span_name}.state.partial"] + + # Map cancellation-specific attributes to metadata + if f"{trpc_span_name}.cancellation.reason" in attributes: + trace_metadata["cancellation_reason"] = attributes[f"{trpc_span_name}.cancellation.reason"] + + if f"{trpc_span_name}.cancellation.agent_name" in attributes: + trace_metadata["cancellation_agent_name"] = attributes[f"{trpc_span_name}.cancellation.agent_name"] + + if f"{trpc_span_name}.cancellation.branch" in attributes: + trace_metadata["cancellation_branch"] = attributes[f"{trpc_span_name}.cancellation.branch"] + + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}.runner."): + # Convert TRPC agent runner attributes to metadata + clean_key = key.replace(f"{trpc_span_name}.runner.", "") + trace_metadata[clean_key] = str(value) + + if trace_metadata: + trace_attrs["langfuse.trace.metadata"] = json.dumps(trace_metadata) + + return trace_attrs + + def _map_agent_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map span observation attributes for agent executions.""" + agent_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Set observation type to span + agent_attrs["langfuse.observation.type"] = "span" + + # Map input/output for agent execution + agent_attrs["langfuse.observation.input"] = attributes.get(f"{trpc_span_name}.agent.input", "") + agent_attrs["langfuse.observation.output"] = attributes.get(f"{trpc_span_name}.agent.output", "") + + return agent_attrs + + def _map_generation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map generation observation attributes for LLM calls.""" + gen_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Set observation type to generation + gen_attrs["langfuse.observation.type"] = "generation" + + # Map input/output for LLM calls + gen_attrs["langfuse.observation.input"] = attributes.get(f"{trpc_span_name}.llm_request", "unknown") + gen_attrs["langfuse.observation.output"] = attributes.get(f"{trpc_span_name}.llm_response", "unknown") + + # Map model parameters from LLM request + llm_request_json = json.loads(attributes.get(f"{trpc_span_name}.llm_request", "{}")) + config = llm_request_json.get("config", {}) + gen_attrs["langfuse.observation.model.parameters"] = json.dumps(config) + + # Map usage details + gen_attrs["gen_ai.usage.input_tokens"] = attributes.get("gen_ai.usage.input_tokens", "0") + gen_attrs["gen_ai.usage.output_tokens"] = attributes.get("gen_ai.usage.output_tokens", "0") + if "gen_ai.request.model" in attributes: + gen_attrs["gen_ai.request.model"] = attributes["gen_ai.request.model"] + + # Map generation metadata + gen_metadata = {} + excluded_keys = {"llm_request", "llm_response", "prompt.name", "prompt.version", "prompt.labels"} + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}."): + clean_key = key.replace(f"{trpc_span_name}.", "") + if clean_key not in excluded_keys: + gen_metadata[clean_key] = str(value) + + # Instruction-Generation association: attributes written by trace_call_llm() + # via RemoteInstruction.metadata, mapped to Langfuse native prompt fields. + instruction_name = attributes.get(f"{trpc_span_name}.instruction.name") + instruction_version = attributes.get(f"{trpc_span_name}.instruction.version") + if instruction_name: + gen_attrs["langfuse.observation.prompt.name"] = instruction_name + if instruction_version is not None: + gen_attrs["langfuse.observation.prompt.version"] = instruction_version + + if gen_metadata: + gen_attrs["langfuse.observation.metadata"] = json.dumps(gen_metadata) + + return gen_attrs + + def _map_tool_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map span observation attributes for tool executions.""" + tool_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Set observation type to span + tool_attrs["langfuse.observation.type"] = "span" + + # Map input/output for tool calls + tool_attrs["langfuse.observation.input"] = attributes.get(f"{trpc_span_name}.tool_call_args", "unknown") + tool_attrs["langfuse.observation.output"] = attributes.get(f"{trpc_span_name}.tool_response", "unknown") + + # Map tool-specific metadata + tool_metadata = {} + + # Map tool name and description + if "gen_ai.tool.name" in attributes: + tool_metadata["tool_name"] = attributes["gen_ai.tool.name"] + + if "gen_ai.tool.description" in attributes: + tool_metadata["tool_description"] = attributes["gen_ai.tool.description"] + + if "gen_ai.tool.call.id" in attributes: + tool_metadata["tool_call_id"] = attributes["gen_ai.tool.call.id"] + + # Map state.begin and state.end to metadata + if f"{trpc_span_name}.state.begin" in attributes: + tool_metadata["state_begin"] = attributes[f"{trpc_span_name}.state.begin"] + + if f"{trpc_span_name}.state.end" in attributes: + tool_metadata["state_end"] = attributes[f"{trpc_span_name}.state.end"] + + # Map other TRPC agent attributes + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}."): + clean_key = key.replace(f"{trpc_span_name}.", "") + # Exclude tool_call_args, tool_response, state.begin, + # state.end from metadata as they're mapped separately + if clean_key not in ["tool_call_args", "tool_response", "state.begin", "state.end"]: + tool_metadata[clean_key] = str(value) + + if tool_metadata: + tool_attrs["langfuse.observation.metadata"] = json.dumps(tool_metadata) + + return tool_attrs + + def _map_span_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map general span observation attributes for other operations.""" + span_attrs = {} + + # Set observation type to span + span_attrs["langfuse.observation.type"] = "span" + + span_attrs.update(attributes) + + return span_attrs + + def _map_attributes_to_old_langfuse(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map attributes to old Langfuse format.""" + langfuse_attributes = {} + # Get the operation name to determine the span type + operation_name = attributes.get("gen_ai.operation.name", "") + + # Map based on operation type + if operation_name == "run_runner" or operation_name == "run_runner_cancelled": + # This is a trace-level span (runner execution or cancelled runner) + langfuse_attributes.update(self._map_old_trace_level_attributes(attributes)) + elif operation_name == "run_agent": + # This is a span observation (agent execution) + langfuse_attributes.update(self._map_old_agent_observation_attributes(attributes)) + elif operation_name == "call_llm": + # This is a generation observation (LLM call) + langfuse_attributes.update(self._map_old_generation_attributes(attributes)) + elif operation_name == "execute_tool": + # This is a span observation (tool execution) + langfuse_attributes.update(self._map_old_tool_observation_attributes(attributes)) + else: + # Default to span observation for unknown operations + langfuse_attributes.update(self._map_old_span_observation_attributes(attributes)) + + return langfuse_attributes + + def _map_old_trace_level_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map trace-level attributes for older Langfuse versions.""" + trace_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Map trace name from runner name + trace_attrs["langfuse.trace.name"] = attributes.get(f"{trpc_span_name}.runner.name", "unknown") + + # Map user ID from runner attributes (old format) + if f"{trpc_span_name}.runner.user_id" in attributes: + trace_attrs["user.id"] = attributes[f"{trpc_span_name}.runner.user_id"] + + # Map session ID from runner attributes (old format) + if f"{trpc_span_name}.runner.session_id" in attributes: + trace_attrs["session.id"] = attributes[f"{trpc_span_name}.runner.session_id"] + + # Map input/output from runner attributes (old format) + if f"{trpc_span_name}.runner.input" in attributes: + trace_attrs["input.value"] = attributes[f"{trpc_span_name}.runner.input"] + + if f"{trpc_span_name}.runner.output" in attributes: + trace_attrs["output.value"] = attributes[f"{trpc_span_name}.runner.output"] + + # Map trace metadata from runner attributes (old format) + trace_metadata = {} + + # Map state.begin, state.end, and state.partial to metadata + if f"{trpc_span_name}.state.begin" in attributes: + trace_metadata["state_begin"] = attributes[f"{trpc_span_name}.state.begin"] + + if f"{trpc_span_name}.state.end" in attributes: + trace_metadata["state_end"] = attributes[f"{trpc_span_name}.state.end"] + + if f"{trpc_span_name}.state.partial" in attributes: + trace_metadata["state_partial"] = attributes[f"{trpc_span_name}.state.partial"] + + # Map cancellation-specific attributes to metadata + if f"{trpc_span_name}.cancellation.reason" in attributes: + trace_metadata["cancellation_reason"] = attributes[f"{trpc_span_name}.cancellation.reason"] + + if f"{trpc_span_name}.cancellation.agent_name" in attributes: + trace_metadata["cancellation_agent_name"] = attributes[f"{trpc_span_name}.cancellation.agent_name"] + + if f"{trpc_span_name}.cancellation.branch" in attributes: + trace_metadata["cancellation_branch"] = attributes[f"{trpc_span_name}.cancellation.branch"] + + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}.runner."): + # Convert TRPC agent runner attributes to metadata + clean_key = key.replace(f"{trpc_span_name}.runner.", "") + trace_metadata[clean_key] = str(value) + + if trace_metadata: + trace_attrs["langfuse.metadata"] = json.dumps(trace_metadata) + + return trace_attrs + + def _map_old_agent_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map span observation attributes for agent executions (older Langfuse versions).""" + agent_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Map input/output for agent execution (old format) + agent_attrs["input.value"] = attributes.get(f"{trpc_span_name}.agent.input", "") + agent_attrs["output.value"] = attributes.get(f"{trpc_span_name}.agent.output", "") + + return agent_attrs + + def _map_old_generation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map generation observation attributes for older Langfuse versions.""" + gen_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Map input/output for LLM calls (old format) + gen_attrs["gen_ai.prompt"] = attributes.get(f"{trpc_span_name}.llm_request", "unknown") + gen_attrs["gen_ai.completion"] = attributes.get(f"{trpc_span_name}.llm_response", "unknown") + + # Map model parameters from LLM request (old format) + llm_request_json = json.loads(attributes.get(f"{trpc_span_name}.llm_request", "{}")) + config = llm_request_json.get("config", {}) + gen_attrs["gen_ai.request.temperature"] = config.get("temperature", 0.7) + gen_attrs["gen_ai.request.max_tokens"] = config.get("max_tokens", 1000) + gen_attrs["gen_ai.request.top_p"] = config.get("top_p", 1.0) + + # Map usage details (old format) + gen_attrs["gen_ai.usage.input_tokens"] = attributes.get("gen_ai.usage.input_tokens", "0") + gen_attrs["gen_ai.usage.output_tokens"] = attributes.get("gen_ai.usage.output_tokens", "0") + gen_attrs["gen_ai.usage.total_tokens"] = attributes.get("gen_ai.usage.total_tokens", "0") + + # Map model name (old format) + if "gen_ai.request.model" in attributes: + gen_attrs["gen_ai.request.model"] = attributes["gen_ai.request.model"] + elif "llm.model_name" in attributes: + gen_attrs["llm.model_name"] = attributes["llm.model_name"] + elif "model" in attributes: + gen_attrs["model"] = attributes["model"] + + # Map generation metadata (old format) + gen_metadata = {} + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}."): + clean_key = key.replace(f"{trpc_span_name}.", "") + # Exclude llm_request and llm_response from metadata as they're mapped separately + if clean_key not in ["llm_request", "llm_response"]: + gen_metadata[clean_key] = str(value) + + if gen_metadata: + gen_attrs["langfuse.metadata"] = json.dumps(gen_metadata) + + return gen_attrs + + def _map_old_tool_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map span observation attributes for older Langfuse versions.""" + tool_attrs = {} + + # Use TRPC agent span name from trace module + trpc_span_name = get_trpc_agent_span_name() + + # Map input/output for tool calls (old format) + tool_attrs["input.value"] = attributes.get(f"{trpc_span_name}.tool_call_args", "unknown") + tool_attrs["output.value"] = attributes.get(f"{trpc_span_name}.tool_response", "unknown") + + # Map tool-specific metadata (old format) + tool_metadata = {} + + # Map tool name and description + if "gen_ai.tool.name" in attributes: + tool_metadata["tool_name"] = attributes["gen_ai.tool.name"] + + if "gen_ai.tool.description" in attributes: + tool_metadata["tool_description"] = attributes["gen_ai.tool.description"] + + if "gen_ai.tool.call.id" in attributes: + tool_metadata["tool_call_id"] = attributes["gen_ai.tool.call.id"] + + # Map state.begin and state.end to metadata + if f"{trpc_span_name}.state.begin" in attributes: + tool_metadata["state_begin"] = attributes[f"{trpc_span_name}.state.begin"] + + if f"{trpc_span_name}.state.end" in attributes: + tool_metadata["state_end"] = attributes[f"{trpc_span_name}.state.end"] + + # Map other TRPC agent attributes + for key, value in attributes.items(): + if key.startswith(f"{trpc_span_name}."): + clean_key = key.replace(f"{trpc_span_name}.", "") + # Exclude tool_call_args, tool_response, + # state.begin, state.end from metadata as they're mapped separately + if clean_key not in ["tool_call_args", "tool_response", "state.begin", "state.end"]: + tool_metadata[clean_key] = str(value) + + if tool_metadata: + tool_attrs["langfuse.metadata"] = json.dumps(tool_metadata) + + return tool_attrs + + def _map_old_span_observation_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]: + """Map general span observation attributes for older Langfuse versions.""" + span_attrs = {} + + # For old mode, just pass through the attributes + span_attrs.update(attributes) + + return span_attrs + + +class _LangfuseSpanProcessor(_LangfuseMixin, SimpleSpanProcessor): + """Custom span processor that maps TRPC agent traces to Langfuse format.""" + + def __init__(self, exporter: SpanExporter): + super().__init__(exporter) + + def on_end(self, span: ReadableSpan) -> None: + """Transform span attributes to Langfuse format before exporting.""" + # Skip spans that should not be exported to Langfuse + if self._should_skip_span(span): + return + + transformed_span = self._transform_span_for_langfuse(span) + logger.debug("Langfuse span processor on_end: %s", transformed_span.to_json()) + super().on_end(transformed_span) + + +class _LangfuseBatchSpanProcessor(_LangfuseMixin, BatchSpanProcessor): + """Custom batch span processor that maps TRPC agent traces to Langfuse format.""" + + def __init__(self, exporter: SpanExporter, **kwargs): + super().__init__(exporter, **kwargs) + + def on_end(self, span: ReadableSpan) -> None: + """Transform span attributes to Langfuse format before exporting.""" + # Skip spans that should not be exported to Langfuse + if self._should_skip_span(span): + return + + transformed_span = self._transform_span_for_langfuse(span) + logger.debug("Langfuse batch span processor on_end: %s", transformed_span.to_json()) + super().on_end(transformed_span) + + +class _LangfuseOTLPExporter(OTLPSpanExporter): + """Custom OTLP exporter configured for Langfuse.""" + + def __init__(self, endpoint: Optional[str] = None, headers: Optional[Dict[str, str]] = None): + super().__init__(endpoint=endpoint, headers=headers) + logger.debug("Langfuse OTLP exporter initialized with endpoint: %s and headers: %s", endpoint, headers) + + def export(self, spans) -> "SpanExportResult": + """Export spans to Langfuse, logging the content before sending.""" + for span in spans: + logger.debug("=== Exporting span to Langfuse ===\n%s", span.to_json(indent=2)) + return super().export(spans) + + +def setup(config: Optional[LangfuseConfig] = None) -> TracerProvider: + """ + Set up OpenTelemetry tracing with Langfuse integration. + + Args: + config: Langfuse configuration. If None, will try to read from environment variables. + + Returns: + Configured TracerProvider + + Raises: + ValueError: If required configuration is missing. + """ + + if config is None: + config = LangfuseConfig() + + # If public_key or secret_key is None, try to get from environment variables + if config.public_key is None: + config.public_key = os.getenv("LANGFUSE_PUBLIC_KEY") + + if config.secret_key is None: + config.secret_key = os.getenv("LANGFUSE_SECRET_KEY") + + # If host is not specified, try to get from environment variable, otherwise use default + if config.host is None: + config.host = os.getenv("LANGFUSE_HOST") + + global _langfuse_config # pylint: disable=invalid-name + # Set the global config and use it in span processor(Only be setted once) + _langfuse_config = config + + # Check if we have the required credentials + if not config.host or not config.public_key or not config.secret_key: + raise ValueError("Missing required Langfuse credentials. Please provide public_key and secret_key " + "either in the config or set the following environment variables:\n" + "export LANGFUSE_PUBLIC_KEY='pk-lf-your-public-key'\n" + "export LANGFUSE_SECRET_KEY='sk-lf-your-secret-key'\n" + "export LANGFUSE_HOST='https://your-langfuse-host.com'") + + # Create auth string + auth_string = base64.b64encode(f"{config.public_key}:{config.secret_key}".encode()).decode() + + # Set up endpoint + config.host = config.host.rstrip("/") + endpoint = f"{config.host}/api/public/otel/v1/traces" + + # Create exporter + headers = {"Authorization": f"Basic {auth_string}"} + exporter = _LangfuseOTLPExporter(endpoint=endpoint, headers=headers) + + # Create span processor + if config.batch_export: + span_processor = _LangfuseBatchSpanProcessor(exporter) + else: + span_processor = _LangfuseSpanProcessor(exporter) + + # Create and configure tracer provider + trace_provider = TracerProvider() + trace_provider.add_span_processor(span_processor) + + # Set as global tracer provider + trace.set_tracer_provider(trace_provider) + + return trace_provider diff --git a/trpc_agent_sdk/server/openclaw/README.md b/trpc_agent_sdk/server/openclaw/README.md new file mode 100644 index 000000000..95d6ae426 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/README.md @@ -0,0 +1,403 @@ +# OpenClaw (trpc_claw) + +`openclaw(又称为trpc_claw)` 是基于 `trpc_agent_sdk` 与 `nanobot` 的智能体运行时,支持: + +- 第三方通道接入(例如:Telegram / WeCom等 nanobot 支持的 channels,这里都支持,参考: [nanobot/channels](https://github.com/HKUDS/nanobot/tree/main/nanobot/channels)) +- 本地 CLI 回退交互 +- 工具调用(文件、Shell、Web、消息、定时任务、MCP、Skills) +- 会话/记忆管理与摘要 +- Heartbeat/Cron 定时触发 + +默认工作目录:`~/.trpc_claw/workspace` + +## 核心能力 + +- **统一处理链路**:不论消息来自 Telegram / WeCom 还是本地 CLI,都进入同一条 `MessageBus -> Runner -> Agent` 管线 +- **双运行模式** + - `run`:有可用通道时走 gateway,无通道自动回退 CLI + - `chat`:强制本地 CLI 模式 +- **可扩展工具体系**:内置文件、执行命令、Web、消息、Cron、MCP 与 Skill 体系 +- **长期可运行**:通过 heartbeat 和 cron 在无人交互时也能定时触发任务 + +## 架构简图 + +```mermaid +flowchart TD + A[trpc_agent_cmd openclaw] --> B[Typer Commands: run/chat/ui/conf_temp/deps] + B --> C[ClawApplication] + C --> D[load_config] + C --> E[MessageBus] + C --> F[ChannelManager] + C --> G[Runner + LlmAgent] + G --> H[Tools / Skills / MCP] + C --> I[CronService] + C --> J[HeartbeatService] + + F -->|enabled channels| K[Telegram / WeCom / other channels] + B -->|chat or no channel| L[CLI fallback loop] + E --> G + I --> G + J --> G +``` + +## 安装与启动 + +### 1) 环境准备 + +- Python `>=3.10`(推荐 `3.12`) +- 建议使用虚拟环境(`uv` 或 `venv`) + +```bash +uv venv +source .venv/bin/activate +python -V +``` + +### 2) 命令入口 + +当前推荐命令入口(与仓库现状一致): + +```bash +trpc_agent_cmd openclaw --help +``` + +### 3) 生成配置模板 + +```bash +mkdir -p ~/.trpc_claw + +# 简版模板 +trpc_agent_cmd openclaw conf_temp > ~/.trpc_claw/config.yaml + +# 完整模板(建议) +trpc_agent_cmd openclaw conf_temp --full > ~/.trpc_claw/config_full.yaml +``` + +### 4) 启动方式 + +```bash +# 自动模式:有通道走 gateway,无通道自动回退 CLI +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml + +# 强制本地 CLI 交互 +trpc_agent_cmd openclaw chat -c ~/.trpc_claw/config_full.yaml + +# 启动 UI +trpc_agent_cmd openclaw ui -c ~/.trpc_claw/config_full.yaml +``` + +## 命令说明 + +- `trpc_agent_cmd openclaw conf_temp [--full]` + - 打印内置配置模板(`config.temp.yaml` / `config_full.temp.yaml`) +- `trpc_agent_cmd openclaw run [-w WORKSPACE] [-c CONFIG]` + - 启动网关模式;若无可用第三方通道,则自动回退到 CLI +- `trpc_agent_cmd openclaw chat [-w WORKSPACE] [-c CONFIG]` + - 始终使用本地 CLI 模式,忽略第三方通道 +- `trpc_agent_cmd openclaw ui [-w WORKSPACE] [-c CONFIG]` + - 启动 UI(macOS 桌面,其它系统浏览器) +- `trpc_agent_cmd openclaw deps [OPTIONS]` + - 检查 Skill 依赖并输出安装计划;支持 `--apply` 执行安装命令 + +### deps 子命令(新增能力) + +```bash +# 按 profile 检查(推荐) +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools + +# 指定 skill 检查 +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --skills {skill-finder} # 用于下载 skill 的 skill 名称,这里只是占位符,用户使用的时候需要修改为自己真正的 skill + +# 输出 JSON +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools \ + --json + +# 直接执行安装计划 +trpc_agent_cmd openclaw deps \ + -c ~/.trpc_claw/config_full.yaml \ + --profile common-file-tools \ + --apply +``` + +常用参数: + +- `--profile`:依赖 profile(逗号分隔) +- `--skills/-s`:按技能名检查依赖(逗号分隔) +- `--state-dir`:工具链状态目录(保留对齐参数) +- `--skills-root`:覆盖 skills root +- `--skills-extra-dirs`:额外 skills root(逗号分隔) +- `--skills-allow-bundled`:覆盖 bundled skill 白名单 +- `--continue-on-error/--fail-fast`:安装执行策略 + +## 配置文件说明 + +配置支持 `YAML/JSON`,默认查找路径: + +- `~/.trpc_claw/config.json` + +你也可以通过以下方式覆盖: + +- 命令行参数:`-c/--config` +- 环境变量:`TRPC_CLAW_CONFIG` + +示例: + +```bash +export TRPC_CLAW_CONFIG=/path/to/config.yaml +trpc_agent_cmd openclaw chat +``` + +## 关键配置项(按当前实现) + +### runtime + +- `app_name`:运行时应用名 +- `user_id`:默认用户 ID +- `legacy_sessions_dir`:历史 sessions 目录兼容路径 + +### agent + +- `workspace`:工作目录(不填时自动回退默认目录) +- `model`:模型名(建议用环境变量 `TRPC_AGENT_MODEL_NAME`) +- `api_key`:模型 API Key(建议用环境变量 `TRPC_AGENT_API_KEY`) +- `api_base`:模型 Base URL(建议用环境变量 `TRPC_AGENT_BASE_URL`) +- `provider/max_tokens/context_window_tokens/...`:模型行为与成本相关参数 + +### channels + +- `send_progress`:是否推送流式进度 +- `send_tool_hints`:是否推送工具提示 +- `telegram.*`:Telegram 通道配置 +- `wecom.*`:WeCom 通道配置 + - `stream_reply`:是否流式分片回复 + - `restart_command`:触发工作进程重启并重载配置的命令(默认 `/restart`) + +### skills(重点更新) + +当前 `openclaw` 使用的字段: + +- `sandbox_type`:当前支持 `local` / `container` +- `skill_roots`:用户 skill 根目录(支持本地目录、`file://`、`http(s)://`) +- `builtin_skill_roots`:内置 skill 根目录 +- `config_keys`:用于满足 skill 元数据中的 `requires.config` +- `allow_bundled`:当仅允许部分内置 skill 暴露时的白名单(按 `skill_key` 或 `name`) +- `skill_configs`:按 `skill_key` 或 `name` 的运行时配置 + - `enabled` + - `env`(当前已统一为 env-only,不再使用 `api_key/primary_env` 映射) +- `local_config` / `container_config`:对应沙箱配置 +- `run_tool_kwargs`:透传 `skill_run` 运行参数 + +示例: + +```yaml +skills: + sandbox_type: container + skill_roots: [] + builtin_skill_roots: [] + config_keys: + - skillhub.enabled + - skillhub + allow_bundled: + - skillhub.skill.finder + - skillhub-skill-finder + skill_configs: + skillhub-skill-finder: + enabled: true + env: + SKILLHUB_USERNAME: ${SKILLHUB_USERNAME} + SKILLHUB_TOKEN: ${SKILLHUB_TOKEN} +``` + +注意这里的 `skillhub-skill-finder` 只是一个例子,需要用户换成自己真正的 skill 名称 + +### tools + +- `restrict_to_workspace`:是否限制工具仅在 workspace 内操作 +- `exec.timeout` / `exec.path_append`:命令执行配置 +- `web.search`:检索 provider 配置(`brave`/`tavily`/`duckduckgo`/`searxng`/`jina`) +- `mcp_servers`:MCP 服务列表(stdio/http) + +### memory / storage / logger / personal + +- `memory.memory_service_config.ttl`:记忆 TTL 策略 +- `storage`:`file` / `redis` / `sql` +- `logger`:日志输出配置 +- `personal`:`SOUL.md/USER.md/TOOLS.md/AGENTS.md` 覆盖路径 + +## 推荐环境变量 + +- `TRPC_AGENT_API_KEY` +- `TRPC_AGENT_BASE_URL` +- `TRPC_AGENT_MODEL_NAME` +- `WECOM_BOT_ID` +- `WECOM_BOT_SECRET` +- `TELEGRAM_BOT_TOKEN` + +## 默认目录 + +- 配置目录:`~/.trpc_claw/` +- 工作目录:`~/.trpc_claw/workspace` + +## 接入 ClawBot + +### 企业微信 + +企业微信创建机器人后获取 `BotID/BotSecret`,并配置: + +```yaml +channels: + wecom: + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + stream_reply: true + restart_command: /restart +``` + +启动: + +```bash +export TRPC_AGENT_API_KEY=xxx +export TRPC_AGENT_BASE_URL=xxx +export TRPC_AGENT_MODEL_NAME=xxx +export WECOM_BOT_ID=xxx +export WECOM_BOT_SECRET=xxx +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml +``` + +![wecom](./image/wecom.png) + +### Telegram + +参考:https://cloud.tencent.com/developer/article/2626214 + +```yaml +channels: + telegram: + enabled: true + token: ${TELEGRAM_BOT_TOKEN} +``` + +启动: + +```bash +export TELEGRAM_BOT_TOKEN=xxx +trpc_agent_cmd openclaw run -c ~/.trpc_claw/config_full.yaml +``` + +![telegram](./image/telegram.png) + +## 高级功能 + +### 1) 多存储后端(`storage`) + +```yaml +storage: + type: redis # file | redis | sql + redis: + url: redis://127.0.0.1:6379 + is_async: false + password: "" + db: 0 + kwargs: {} +``` + +```yaml +storage: + type: sql + sql: + url: sqlite:///./session_memory.db + is_async: false + kwargs: {} +``` + +说明: + +- `type=file`:短期会话默认落盘到 `workspace/sessions/*.jsonl` +- `type=redis/sql`:短期会话与记忆可走共享后端 + +### 2) 记忆 TTL(`memory.memory_service_config.ttl`) + +```yaml +memory: + memory_service_config: + enabled: true + ttl: + enable: true + ttl_seconds: 86400 + cleanup_interval_seconds: 3600 + update_time: 0.0 +``` + +### 3) Agent 高级参数(`agent`) + +```yaml +agent: + provider: auto + max_tokens: 8192 + context_window_tokens: 65536 + temperature: 0.1 + max_tool_iterations: 40 + reasoning_effort: null # low | medium | high + extra_headers: {} +``` + +### 4) MCP 服务接入(`tools.mcp_servers`) + +```yaml +tools: + mcp_servers: + fs: + type: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + env: {} + tool_timeout: 30 + enabled_tools: ["*"] +``` + +### 5) 搜索 Provider 扩展(`tools.web.search`) + +```yaml +tools: + web: + search: + provider: brave # brave | tavily | duckduckgo | searxng | jina + api_key: "" + base_url: "" + max_results: 5 +``` + +### 6) 日志与个人提示词文件 + +```yaml +logger: + name: trpc_claw + log_file: trpc_claw.log + log_level: INFO + log_format: "[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s" +``` + +```yaml +personal: + soul_file: /path/to/SOUL.md + user_file: /path/to/USER.md + tool_file: /path/to/TOOLS.md + agent_file: /path/to/AGENTS.md +``` + +## 目录参考(当前仓库) + +- [trpc_agent_sdk/server/openclaw/_cli.py](../../../trpc_agent_sdk/server/openclaw/_cli.py):OpenClaw CLI 命令 +- [trpc_agent_sdk/server/openclaw/claw.py](../../../trpc_agent_sdk/server/openclaw/claw.py):运行时核心编排 +- [trpc_agent_sdk/server/openclaw/config/](../../../trpc_agent_sdk/server/openclaw/config/):配置模型与加载逻辑 +- [trpc_agent_sdk/server/openclaw/channels/](../../../trpc_agent_sdk/server/openclaw/channels/):通道适配(Telegram / WeCom) +- [trpc_agent_sdk/server/openclaw/tools/](../../../trpc_agent_sdk/server/openclaw/tools/):工具实现 +- [trpc_agent_sdk/server/openclaw/skill/](../../../trpc_agent_sdk/server/openclaw/skill/):Skill 体系(加载、解析、依赖检查) +- [trpc_agent_sdk/server/openclaw/service/](../../../trpc_agent_sdk/server/openclaw/service/):Cron / Heartbeat 服务 diff --git a/trpc_agent_sdk/server/openclaw/__init__.py b/trpc_agent_sdk/server/openclaw/__init__.py new file mode 100644 index 000000000..bc6e483f9 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/__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 Apache-2.0. diff --git a/trpc_agent_sdk/server/openclaw/_cli.py b/trpc_agent_sdk/server/openclaw/_cli.py new file mode 100644 index 000000000..22d52da67 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/_cli.py @@ -0,0 +1,226 @@ +# 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. +"""CLI entry point for OpenClaw runtime. + +This module provides a nanobot-style command layout while staying aligned with +the current trpc_agent_sdk runtime implementation. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Optional + +import typer + +CLI_COMMAND_PATH = ("openclaw", ) +CLI_COMMAND_HELP = "OpenClaw gateway, chat, ui and deps tools." + +app = typer.Typer( + help="trpc_claw gateway and interactive CLI.", + no_args_is_help=True, + add_completion=False, +) + + +@app.command("conf_temp") +def show_config_template_cmd( + full: bool = typer.Option( + False, + "--full", + help="Print full config template (config_full.temp.yaml).", + ), ) -> None: + """Print the openclaw config template YAML.""" + template_name = "config_full.temp.yaml" if full else "config.temp.yaml" + template_path = Path(__file__).with_name(template_name) + if not template_path.exists(): + typer.echo(f"Error: template not found: {template_path}", err=True) + raise typer.Exit(code=1) + typer.echo(template_path.read_text(encoding="utf-8")) + + +def _resolve_optional_path(value: Optional[str]) -> Optional[Path]: + return Path(value).expanduser().resolve() if value else None + + +def _run_openclaw(workspace: Optional[str], config: Optional[str], force_chat: bool = False) -> None: + from trpc_agent_sdk.server.openclaw.claw import ClawApplication + ws = _resolve_optional_path(workspace) + cfg = _resolve_optional_path(config) + + async def _run() -> None: + gateway = ClawApplication(workspace=ws, config_path=cfg) + if force_chat or not gateway.channels.enabled_channels: + await gateway.run_cli_fallback() + return + await gateway.run_gateway() + + asyncio.run(_run()) + + +def _run_openclaw_ui( + workspace: Optional[str], + config: Optional[str], +) -> None: + from trpc_agent_sdk.server.openclaw.ui import run_ui_server + ws = _resolve_optional_path(workspace) + cfg = _resolve_optional_path(config) + run_ui_server(workspace=ws, config_path=cfg) + + +@app.command("run") +def run_cmd( + workspace: Optional[str] = typer.Option( + None, + "--workspace", + "-w", + help="Workspace path override (defaults to config/runtime settings).", + ), + config: Optional[str] = typer.Option( + None, + "--config", + "-c", + help="Config file path (yaml/json).", + ), +) -> None: + """Run openclaw in gateway mode when channels are enabled, else CLI fallback.""" + _run_openclaw(workspace=workspace, config=config, force_chat=False) + + +@app.command("chat") +def chat_cmd( + workspace: Optional[str] = typer.Option( + None, + "--workspace", + "-w", + help="Workspace path override (defaults to config/runtime settings).", + ), + config: Optional[str] = typer.Option( + None, + "--config", + "-c", + help="Config file path (yaml/json).", + ), +) -> None: + """Force local interactive chat (CLI fallback), ignoring third-party channels for openclaw.""" + _run_openclaw(workspace=workspace, config=config, force_chat=True) + + +@app.command("ui") +def ui_cmd( + workspace: Optional[str] = typer.Option( + None, + "--workspace", + "-w", + help="Workspace path override (defaults to config/runtime settings).", + ), + config: Optional[str] = typer.Option( + None, + "--config", + "-c", + help="Config file path (yaml/json).", + ), +) -> None: + """Start UI (macOS desktop, browser on other systems).""" + _run_openclaw_ui(workspace=workspace, config=config) + + +@app.command("deps") +def deps_cmd( + profile: str = typer.Option( + "", + "--profile", + help="Dependency profiles (comma-separated).", + ), + skills: str = typer.Option( + "", + "--skills", + "-s", + help="Comma-separated skill names.", + ), + state_dir: str = typer.Option( + "", + "--state-dir", + help="State dir for managed toolchain (reserved for parity).", + ), + skills_root: str = typer.Option( + "", + "--skills-root", + help="Skills root directory override.", + ), + skills_extra_dirs: str = typer.Option( + "", + "--skills-extra-dirs", + help="Extra skills roots (comma-separated).", + ), + skills_allow_bundled: str = typer.Option( + "", + "--skills-allow-bundled", + help="Comma-separated allowlist of bundled skills.", + ), + json_output: bool = typer.Option( + False, + "--json", + help="Print machine-readable JSON output.", + ), + apply: bool = typer.Option( + False, + "--apply", + help="Execute install plan commands.", + ), + continue_on_error: bool = typer.Option( + True, + "--continue-on-error/--fail-fast", + help="Continue executing remaining plan steps when one command fails.", + ), + workspace: Optional[str] = typer.Option( + None, + "--workspace", + "-w", + help="Workspace path override (defaults to config/runtime settings).", + ), + config: Optional[str] = typer.Option( + None, + "--config", + "-c", + help="Config file path (yaml/json).", + ), +) -> None: + """Inspect skill dependencies and print install plan suggestions.""" + from trpc_agent_sdk.server.openclaw.skill import inspect_skill_dependencies + from trpc_agent_sdk.server.openclaw.skill import apply_dependency_plan + from trpc_agent_sdk.server.openclaw.skill import render_dependency_report + from trpc_agent_sdk.server.openclaw.skill import report_to_json + + ws = _resolve_optional_path(workspace) + cfg = _resolve_optional_path(config) + try: + report = inspect_skill_dependencies( + config_path=cfg, + workspace=ws, + skills_raw=skills, + profiles_raw=profile, + state_dir=state_dir, + skills_root=skills_root, + skills_extra_dirs_raw=skills_extra_dirs, + skills_allow_bundled_raw=skills_allow_bundled, + ) + report["apply_requested"] = apply + if apply: + report["apply_result"] = apply_dependency_plan( + report, + continue_on_error=continue_on_error, + ) + except Exception as exc: # pylint: disable=broad-except + typer.echo(f"Error: {exc}", err=True) + raise typer.Exit(code=1) + if json_output: + typer.echo(report_to_json(report)) + else: + typer.echo(render_dependency_report(report)) + if apply and report.get("apply_result", {}).get("has_failures"): + raise typer.Exit(code=1) diff --git a/trpc_agent_sdk/server/openclaw/_logger.py b/trpc_agent_sdk/server/openclaw/_logger.py new file mode 100644 index 000000000..fcf91b775 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/_logger.py @@ -0,0 +1,76 @@ +# 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. +"""This file is used to forward nanobot/loguru logs to trpc_agent_sdk logger.""" + +import logging + +from loguru import logger as _loguru_logger +from trpc_agent_sdk.log import DefaultLogger +from trpc_agent_sdk.log import LogLevel +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.log import set_logger + +from .config import LoggerConfig + +_LOGURU_BRIDGE_ENABLED = False + + +def setup_loguru_bridge() -> None: + """Forward nanobot/loguru logs to trpc_agent_sdk logger.""" + global _LOGURU_BRIDGE_ENABLED + if _LOGURU_BRIDGE_ENABLED: + return + + def _sink(message) -> None: + record = message.record + level = str(record.get("level").name).upper() + rendered = str(record.get("message", "")).rstrip() + if not rendered: + return + source_file = record.get("file").path if record.get("file") else "unknown" + source_line = record.get("line", 0) + text = f"[nanobot:{source_file}:{source_line}] {rendered}" + + if level in {"TRACE", "DEBUG"}: + logger.debug("%s", text) + elif level == "INFO": + logger.info("%s", text) + elif level == "WARNING": + logger.warning("%s", text) + else: + logger.error("%s", text) + + # Replace default loguru sinks to avoid duplicate output. + _loguru_logger.remove() + _loguru_logger.add(_sink, level="DEBUG", enqueue=True) + _LOGURU_BRIDGE_ENABLED = True + + +def default_logger() -> DefaultLogger: + lg = DefaultLogger(name="trpc_claw", min_level=LogLevel.INFO) + file_handler = logging.FileHandler("trpc_claw.log", encoding="utf-8") + file_handler.setFormatter( + logging.Formatter( + "[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + )) + lg.logger.addHandler(file_handler) + return lg + + +def init_claw_logger(config: LoggerConfig) -> None: + log_level = LogLevel[config.log_level.upper()] + lg = DefaultLogger(name=config.name, min_level=log_level) + if config.log_file: + file_handler = logging.FileHandler(config.log_file, encoding="utf-8") + file_handler.setFormatter(logging.Formatter( + config.log_format, + datefmt="%Y-%m-%d %H:%M:%S", + )) + lg.logger.addHandler(file_handler) + set_logger(lg) + setup_loguru_bridge() + return lg diff --git a/trpc_agent_sdk/server/openclaw/_utils.py b/trpc_agent_sdk/server/openclaw/_utils.py new file mode 100644 index 000000000..4b4c78220 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/_utils.py @@ -0,0 +1,153 @@ +# 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. +"""This file is used to parse the origin of an inbound message.""" + +import mimetypes +from pathlib import Path +from typing import Optional + +from nanobot.bus.events import InboundMessage +from nanobot.utils.helpers import detect_image_mime +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.types import Part + + +def parse_origin(msg: InboundMessage) -> tuple[str, str]: + """Resolve delivery origin channel/chat from inbound message. + + nanobot subagent announcements are injected as system messages where + `chat_id` encodes `:`. + """ + if msg.channel != "system": + return msg.channel, msg.chat_id + + if ":" in msg.chat_id: + return msg.chat_id.split(":", 1) + return "cli", msg.chat_id + + +# Channels that don't support stream progress +CHANNELS_WITHOUT_STREAM_PROGRESS: set[str] = {"cli", "telegram", "wecom"} + + +def is_channel_supports_stream_progress(channel: str) -> bool: + """Check if the channel supports stream progress. + + Args: + channel: The channel name. + + Returns: + bool: True if the channel supports stream progress, False otherwise. + """ + return channel not in CHANNELS_WITHOUT_STREAM_PROGRESS + + +def register_channel_without_stream_progress(channel: str) -> None: + """Register the channel without stream progress. + + Args: + channel: The channel name. + """ + CHANNELS_WITHOUT_STREAM_PROGRESS.add(channel) + + +def merge_assistant_text(current: str, incoming: str) -> str: + """Merge assistant text chunks while avoiding cumulative duplicates. + + Args: + current: The current text. + incoming: The incoming text. + + Returns: + str: The merged text. + """ + if not incoming: + return current + if not current: + return incoming + + # Provider may send cumulative text (incoming starts with current). + if incoming.startswith(current): + return incoming + # Exact / trailing duplicate. + if current.endswith(incoming): + return current + # Containment fallback. + if incoming in current: + return current + if current in incoming: + return incoming + return current + incoming + + +def build_user_parts(query: str, media: Optional[list[str]] = None) -> list[Part]: + """Build user parts with optional image attachments. + + Keep behavior aligned with nanobot: + - only image media is injected into model input + - non-image media (for example video/audio/doc files) is ignored + + Args: + query: The query text. + media: The media paths. + + Returns: + list[Part]: The user parts. + """ + parts: list[Part] = [] + for media_path in media or []: + try: + file_path = Path(media_path) + if not file_path.is_file(): + continue + raw = file_path.read_bytes() + mime = detect_image_mime(raw) or mimetypes.guess_type(str(file_path))[0] + if not mime or not mime.startswith("image/"): + continue + parts.append(Part.from_bytes(data=raw, mime_type=mime)) + except Exception as ex: # pylint: disable=broad-except + logger.warning("Skip invalid media '%s': %s", media_path, ex) + continue + + parts.append(Part.from_text(text=query)) + return parts + + +def merge_raw_events(existing: list[Event], recent: list[Event]) -> list[Event]: + """Merge raw archive with recent events while avoiding duplicates. + + Args: + existing: The existing events. + recent: The recent events. + + Returns: + list[Event]: The merged events. + """ + merged: list[Event] = [] + seen_ids: set[str] = set() + for event in [*(existing or []), *(recent or [])]: + event_id = str(getattr(event, "id", "") or "") + if event_id: + if event_id in seen_ids: + continue + seen_ids.add(event_id) + merged.append(event) + return merged + + +def write_file(src: Path, dest: Path, force: bool = False): + """Write file to destination. + + Args: + src: The source file. + dest: The destination file. + force: Whether to force write the file. + """ + if dest.exists() and not force: + return + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8") diff --git a/trpc_agent_sdk/server/openclaw/agent/__init__.py b/trpc_agent_sdk/server/openclaw/agent/__init__.py new file mode 100644 index 000000000..03cf9a24d --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/agent/__init__.py @@ -0,0 +1,18 @@ +# 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. +"""Agent module for trpc_claw.""" + +from ._agent import create_agent +from ._agent import create_model +from ._agent import create_worker_agent +from ._prompts import ClawPrompts + +__all__ = [ + "create_agent", + "create_model", + "create_worker_agent", + "ClawPrompts", +] diff --git a/trpc_agent_sdk/server/openclaw/agent/_agent.py b/trpc_agent_sdk/server/openclaw/agent/_agent.py new file mode 100644 index 000000000..428be49c3 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/agent/_agent.py @@ -0,0 +1,219 @@ +# 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. +"""ClawAgent definition. + +This module only defines and wires the agent objects — no runtime loop. +Runtime dispatch (session management, user I/O) is handled by the caller, +e.g. examples/quickstart/run_agent.py. + +Architecture (mirrors nanobot AgentLoop): + + claw (main LlmAgent) — orchestrator; receives every user message + ├─ all standard tools — exec, filesystem, web, message, skills + └─ sub_agents: [claw_worker] + + claw_worker (worker LlmAgent) — background / long-running task executor + └─ execution tools only — exec, filesystem, web + +Runtime-injected state (via agent_context metadata, NOT constructor args): + - MessageTool callback → MESSAGE_CALLBACK_KEY + - CronTool channel/chat → CRON_CHANNEL_KEY, CRON_CHAT_ID_KEY +""" + +from __future__ import annotations + +from typing import Optional + +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 import preload_memory_tool +from trpc_agent_sdk.types import GenerateContentConfig +from trpc_agent_sdk.types import HttpOptions + +from ..config import BOT_NAME +from ..config import ClawConfig +from ..service import CronService +from ..skill import create_skill_tool_set +from ..tools import CronTool +from ..tools import EditFileTool +from ..tools import ExecTool +from ..tools import ListDirTool +from ..tools import MessageTool +from ..tools import ReadFileTool +from ..tools import SpawnTaskTool +from ..tools import WebFetchTool +from ..tools import WebSearchTool +from ..tools import WriteFileTool +from ..tools import build_mcp_toolsets +from ._prompts import ClawPrompts + +# --------------------------------------------------------------------------- +# Worker / sub-agent prompt +# --------------------------------------------------------------------------- + +_WORKER_INSTRUCTION = """You are a focused background task executor. + +Your job is to complete the assigned task step by step using the available tools. +Report your results clearly and concisely when finished. + +Guidelines: +- Read files before modifying them. +- Run shell commands carefully; check output before proceeding. +- If an error occurs, analyse it and retry with a corrected approach. +- When the task is done, summarize what you did and what the outcome was. +""" + + +def create_model(config: ClawConfig) -> LLMModel: + """Create a model. + + Args: + config: ClawConfig instance. + + Returns: + LLMModel instance. + """ + if not config.model_api_key or not config.model_base_url or not config.model_name: + raise ValueError("Model config missing. Set runtime.model_* in config or " + "TRPC_AGENT_API_KEY/TRPC_AGENT_BASE_URL/TRPC_AGENT_MODEL_NAME.") + model = OpenAIModel(model_name=config.model_name, api_key=config.model_api_key, base_url=config.model_base_url) + return model + + +def create_worker_agent( + config: ClawConfig, + model: LLMModel, + *, + generate_content_config: Optional[GenerateContentConfig] = None, +) -> LlmAgent: + """Create the sub-agent(worker / background) agent. + + The worker handles long-running or specialized tasks spawned by the + parent agent. It does **not** have access to MessageTool + to keep its surface small and predictable. + + Args: + model: LLM instance to use. + config: ClawConfig instance. + generate_content_config: GenerateContentConfig instance. + + Returns: + Configured :class:`LlmAgent` instance. + """ + allowed_dir = config.workspace if config.tools.restrict_to_workspace else None + workspace = config.workspace + + return LlmAgent( + name=f"{BOT_NAME}_worker", + description=("A background task executor. Handles long-running or specialized " + "tasks delegated by the main claw agent."), + model=model, + instruction=_WORKER_INSTRUCTION, + tools=[ + ReadFileTool(workspace=workspace, allowed_dir=allowed_dir), + WriteFileTool(workspace=workspace, allowed_dir=allowed_dir), + EditFileTool(workspace=workspace, allowed_dir=allowed_dir), + ListDirTool(workspace=workspace, allowed_dir=allowed_dir), + ExecTool( + working_dir=str(workspace), + restrict_to_workspace=config.tools.restrict_to_workspace, + ), + WebSearchTool(config=config.tools.web.search, proxy=config.tools.web.proxy), + WebFetchTool(), + preload_memory_tool, + ], + generate_content_config=generate_content_config, + ) + + +def create_agent( + config: ClawConfig, + model: LLMModel, + *, + cron_service: Optional["CronService"] = None, + worker_agent: Optional[LlmAgent] = None, +) -> LlmAgent: + """Create the main agent(main / orchestrator) claw agent. + + Wires together all static tools and the system prompt. Runtime-dynamic + state (message callbacks, cron channel) is injected via + ``agent_context`` metadata by the caller — no mutation needed here. + + Args: + config: ClawConfig instance. + model: LLM instance to use. + cron_service: nanobot :class:`CronService`; when provided a + :class:`CronTool` is added for scheduling. + worker_agent: Override the auto-created worker sub-agent. + + Returns: + Configured :class:`LlmAgent` instance (main agent). + """ + workspace = config.workspace + allowed_dir = workspace if config.tools.restrict_to_workspace else None + if not workspace.exists(): + workspace.mkdir(parents=True, exist_ok=True) + + # ── System prompt ──────────────────────────────────────────────────────── + system_prompt = ClawPrompts(config=config).build_system_prompt() + + generate_content_config = GenerateContentConfig( + temperature=config.agent.temperature, + max_output_tokens=config.agent.max_tokens, + http_options=HttpOptions(headers=config.model_extra_headers, ), + ) + # ── Sub (worker) agent ─────────────────────────────────────────────────── + child = worker_agent or create_worker_agent( + config=config, + model=model, + generate_content_config=generate_content_config, + ) + + # ── Tools ──────────────────────────────────────────────────────────────── + skill_tool_set = create_skill_tool_set(config) + tools = [ + # Filesystem + ReadFileTool(workspace=workspace, allowed_dir=allowed_dir), + WriteFileTool(workspace=workspace, allowed_dir=allowed_dir), + EditFileTool(workspace=workspace, allowed_dir=allowed_dir), + ListDirTool(workspace=workspace, allowed_dir=allowed_dir), + # Shell execution + ExecTool( + working_dir=str(workspace), + restrict_to_workspace=config.tools.restrict_to_workspace, + ), + # Web + WebSearchTool(config=config.tools.web.search, proxy=config.tools.web.proxy), + WebFetchTool(), + # Messaging — send_callback injected at runtime via agent_context + MessageTool(), + # Async background task dispatch — callback injected at runtime + SpawnTaskTool(), + # Skills + skill_tool_set, + preload_memory_tool, + ] + + # Optional runtime-dependent tools + tools.extend(build_mcp_toolsets(config.tools.mcp_servers)) + + if cron_service is not None: + tools.append(CronTool(cron_service=cron_service)) + + # ── Parent agent ───────────────────────────────────────────────────────── + + return LlmAgent( + name=BOT_NAME, + description="A helpful AI assistant powered by claw.", + model=model, + instruction=system_prompt, + tools=tools, + skill_repository=skill_tool_set.repository, + generate_content_config=generate_content_config, + # claw_worker is accessible via transfer_to_agent + sub_agents=[child], + ) diff --git a/trpc_agent_sdk/server/openclaw/agent/_prompts.py b/trpc_agent_sdk/server/openclaw/agent/_prompts.py new file mode 100644 index 000000000..601d5698a --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/agent/_prompts.py @@ -0,0 +1,137 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# This file is part of tRPC-Agent-Python and is licensed under Apache-2.0. +# +# Portions of this file are derived from HKUDS/nanobot (MIT License): +# https://github.com/HKUDS/nanobot.git +# +# Copyright (c) 2025 nanobot contributors +# +# See the project LICENSE / third-party attribution notices for details. +# +"""Prompts for trpc_claw.""" + +import platform +from pathlib import Path + +from trpc_agent_sdk.log import logger + +from ..config import BOT_NAME +from ..config import ClawConfig +from ..config import HISTORY_FILE_NAME +from ..config import MEMORY_FILE_NAME + +INSTRUCTION_DEFAULT: str = "You are a helpful assistant." +SYSTEM_PROMPT_DEFAULT: str = f"You name is {BOT_NAME}." +TOOL_AND_SKILL_FALLBACK: str = ( + "- If there is no direct tool that can answer/execute the request, first list available skills " + "and select the relevant skill.\n" + "- If a matching skill exists, call the skill directly instead of giving up or answering vaguely.\n" + "- If a required skill is unavailable due to missing dependencies, clearly tell the user what is " + "missing and suggest installing it first.\n" + "- For skill shell execution, never fabricate command names. Use exact executable commands from " + "loaded skill docs (for example, `curl ...` shown in SKILL.md).") + +REPLY_POLICY = ("Reply directly with text for conversations. Only use the 'message' tool " + "to send to a specific chat channel.") + + +class ClawPrompts: + """trpc_claw prompts.""" + + def __init__(self, config: ClawConfig, silent: bool = False): + """Initialize prompts.""" + self.workspace = config.workspace + self.silent = silent + self.config = config + + def _load_bootstrap_files(self) -> str: + """Load all bootstrap files from workspace.""" + parts = [] + for filename in self.config.personal: + file_path = Path(filename).resolve() + if not file_path.exists(): + logger.warning("File %s not found", filename) + continue + filename = f"{file_path.stem.upper()}{file_path.suffix}" + if filename in {HISTORY_FILE_NAME, MEMORY_FILE_NAME}: + dest_file = self.workspace / "memory" / filename + else: + dest_file = self.workspace / filename + content = file_path.read_text(encoding="utf-8") + dest_file.write_text(content, encoding="utf-8") + + parts.append(f"## {filename}\n\n{content}") + + return "\n\n".join(parts) if parts else "" + + def build_system_prompt(self) -> str: + """Build the system prompt from identity, bootstrap files, memory, and skills.""" + + # 1. Identity + parts = [self._get_identity()] + + # 2. Bootstrap files + bootstrap = self._load_bootstrap_files() + if bootstrap: + parts.append(bootstrap) + + return "\n\n---\n\n".join(parts) + + def _get_identity(self) -> str: + """Get the core identity section.""" + workspace_path = str(self.workspace.expanduser().resolve()) + system = platform.system() + runtime = (f"{'macOS' if system == 'Darwin' else system} {platform.machine()}" + f", Python {platform.python_version()}") + instruction = self.config.agent.instruction or INSTRUCTION_DEFAULT + system_prompt = self.config.agent.system_prompt or SYSTEM_PROMPT_DEFAULT + long_term_memory = (f"- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)") + history_log = (f"- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). " + "Each entry starts with [YYYY-MM-DD HH:MM].") + custom_skills = f"- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md" + + platform_policy = "" + if system == "Windows": + platform_policy = """## Platform Policy (Windows) +- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist. +- Prefer Windows-native commands or file tools when they are more reliable. +- If terminal output is garbled, retry with UTF-8 output enabled. +""" + else: + platform_policy = """## Platform Policy (POSIX) +- You are running on a POSIX system. Prefer UTF-8 and standard shell tools. +- Use file tools when they are simpler or more reliable than shell commands. +""" + + return f"""# {BOT_NAME} + +{instruction} + +{system_prompt} + +## Runtime +{runtime} + +## Workspace +Your workspace is at: {workspace_path} +{long_term_memory} +{history_log} +{custom_skills} + +{platform_policy} + +## {BOT_NAME} Guidelines +- State intent before tool calls, but NEVER predict or claim results before receiving them. +- Before modifying a file, read it first. Do not assume files or directories exist. +- After writing or editing a file, re-read it if accuracy matters. +- If a tool call fails, analyze the error before retrying with a different approach. +- Ask for clarification when the request is ambiguous. + +{REPLY_POLICY} + +## Tool and Skill Fallback +{TOOL_AND_SKILL_FALLBACK} +""" diff --git a/trpc_agent_sdk/server/openclaw/channels/__init__.py b/trpc_agent_sdk/server/openclaw/channels/__init__.py new file mode 100644 index 000000000..3450756d7 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/channels/__init__.py @@ -0,0 +1,22 @@ +# 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. +"""Channels for trpc_claw.""" + +from ._command_handler import TrpcClawCommandHandler +from ._command_handler import TrpcClawCommandHandlerParams +from ._repair import register_channel_repair +from ._repair import repair_channels +from ._telegram import repair_telegram_channel +from ._wecom import repair_wecom_channel + +__all__ = [ + "repair_telegram_channel", + "repair_wecom_channel", + "register_channel_repair", + "repair_channels", + "TrpcClawCommandHandler", + "TrpcClawCommandHandlerParams", +] diff --git a/trpc_agent_sdk/server/openclaw/channels/_command_handler.py b/trpc_agent_sdk/server/openclaw/channels/_command_handler.py new file mode 100644 index 000000000..bc8f93526 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/channels/_command_handler.py @@ -0,0 +1,181 @@ +# 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. + +from __future__ import annotations + +import asyncio +import os +import sys +from dataclasses import dataclass +from dataclasses import field +from typing import Awaitable +from typing import Callable +from typing import TypeAlias + +from nanobot.bus.events import InboundMessage +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.sessions import BaseSessionService + +from .._utils import parse_origin +from ..config import ClawConfig +from ..config import DEFAULT_USER_ID + + +@dataclass +class TrpcClawCommandHandlerParams: + """Command handler for trpc_claw.""" + config: ClawConfig + bus: MessageBus + session_service: BaseSessionService + active_tasks: dict[str, list[asyncio.Task]] = field(default_factory=dict) + background_tasks: dict[str, list[asyncio.Task]] = field(default_factory=dict) + + +TrpcClawCommandHandlerCallback: TypeAlias = Callable[[InboundMessage, TrpcClawCommandHandlerParams], Awaitable[None]] + + +class TrpcClawCommandHandler: + """Command handler for trpc_claw.""" + + def __init__(self, + params: TrpcClawCommandHandlerParams, + callback: dict[str, TrpcClawCommandHandlerCallback] | None = None): + self.params = params + self.callbacks = { + "/stop": self._handle_stop, + "/new": self._handle_new, + "/help": self._handle_help, + "/restart": self._handle_restart, + "/quit": self._handle_quit, + "/exit": self._handle_quit, + "quit": self._handle_quit, + "exit": self._handle_quit, + } + if callback: + self.callbacks.update(callback) + self.restarting = False + + async def handle(self, msg: InboundMessage) -> bool: + """Handle the command. + Args: + msg: The inbound message. + Returns: + bool: True if the command is handled, False otherwise. + """ + if msg.content.strip().lower() in self.callbacks: + await self.callbacks[msg.content.strip().lower()](msg, self.params) + return True + + return False + + async def _handle_stop(self, msg: InboundMessage, params: TrpcClawCommandHandlerParams) -> None: + """Cancel active tasks for the current session key.""" + tasks = params.active_tasks.pop(msg.session_key, []) + bg_tasks = params.background_tasks.pop(msg.session_key, []) + cancelled = 0 + for t in tasks: + if not t.done(): + t.cancel() + cancelled += 1 + for t in bg_tasks: + if not t.done(): + t.cancel() + cancelled += 1 + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + if bg_tasks: + await asyncio.gather(*bg_tasks, return_exceptions=True) + content = f"Stopped {cancelled} task(s)." if cancelled else "No active task to stop." + await params.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=content, + )) + + async def _handle_new(self, msg: InboundMessage, params: TrpcClawCommandHandlerParams) -> None: + """Start a new conversation by clearing current session state.""" + # Cancel any in-flight work for this session first. + await self._handle_stop(msg, params) + + channel, _ = parse_origin(msg) + user_id = msg.sender_id or f"{channel}_{DEFAULT_USER_ID}" + app_name = params.config.runtime.app_name + + try: + await params.session_service.delete_session( + app_name=app_name, + user_id=user_id, + session_id=msg.session_key, + ) + except Exception: + logger.error("Failed to clear session for /new: %s", msg.session_key) + await params.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Failed to start a new session. Please try again.", + )) + return + + await params.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="New session started.", + )) + + async def _handle_help(self, msg: InboundMessage, params: TrpcClawCommandHandlerParams) -> None: + """Show available runtime commands.""" + await params.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content=("OpenClaw commands:\n" + "/new — Start a new conversation\n" + "/stop — Stop active tasks in current session\n" + "/restart — Restart worker process and reload config\n" + "/quit|/exit (or quit|exit) — End current interaction\n" + "/help — Show available commands"), + )) + + async def _handle_quit(self, msg: InboundMessage, params: TrpcClawCommandHandlerParams) -> None: + """End current interaction for this channel/session.""" + await params.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Bye.", + )) + + async def _handle_restart(self, msg: InboundMessage, params: TrpcClawCommandHandlerParams) -> None: + """Restart worker process (WeCom command only).""" + if self.restarting: + await params.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Restart already in progress...", + )) + return + self.restarting = True + await params.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Restarting worker process and reloading config...", + )) + asyncio.create_task(self._restart_process()) + + async def _restart_process(self, *, delay_s: float = 0.5) -> None: + """Replace current process to reload config/runtime state.""" + await asyncio.sleep(delay_s) + logger.warning("Restarting process to reload config") + try: + argv = [sys.executable, *sys.argv] + os.execv(sys.executable, argv) + except Exception as ex: # pylint: disable=broad-except + logger.error("Failed to restart process: %s", ex) diff --git a/trpc_agent_sdk/server/openclaw/channels/_repair.py b/trpc_agent_sdk/server/openclaw/channels/_repair.py new file mode 100644 index 000000000..3c38c6fe3 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/channels/_repair.py @@ -0,0 +1,30 @@ +# 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. +"""This file is used to repair channels.""" + +from typing import Callable +from typing import Dict + +from nanobot.channels.manager import ChannelManager +from trpc_agent_sdk.log import logger + +_channels_to_repair: Dict[str, Callable[[str, ChannelManager], None]] = {} + + +def register_channel_repair(channel_name: str, repair_func: Callable[[str, ChannelManager], None]) -> None: + """Register a channel to be repaired.""" + _channels_to_repair[channel_name] = repair_func + + +def repair_channels(channel_manager: ChannelManager) -> None: + """Repair all channels.""" + for name, repair_func in _channels_to_repair.items(): + try: + logger.debug(f"Repairing channel {name}...") + repair_func(name, channel_manager) + logger.debug(f"Channel {name} repaired successfully.") + except Exception as e: + logger.error(f"Failed to repair channel {name}: {e}") diff --git a/trpc_agent_sdk/server/openclaw/channels/_telegram.py b/trpc_agent_sdk/server/openclaw/channels/_telegram.py new file mode 100644 index 000000000..bf1c176d5 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/channels/_telegram.py @@ -0,0 +1,67 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# This file is part of tRPC-Agent-Python and is licensed under Apache-2.0. +# +# Portions of this file are derived from HKUDS/nanobot (MIT License): +# https://github.com/HKUDS/nanobot.git +# +# Copyright (c) 2025 nanobot contributors +# +# See the project LICENSE / third-party attribution notices for details. +# +"""Telegram channel for trpc_claw.""" + +from nanobot.channels.manager import ChannelManager +from nanobot.channels.telegram import TelegramChannel as NanobotTelegramChannel +from telegram import Update +from telegram.ext import ContextTypes + +from ..config import BOT_NAME +from ._repair import register_channel_repair + + +class TelegramChannel(NanobotTelegramChannel): + """Telegram channel for trpc_claw.""" + + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /start command.""" + if not update.message or not update.effective_user: + return + + user = update.effective_user + await update.message.reply_text(f"👋 Hi {user.first_name}! I'm {BOT_NAME}.\n\n" + "Send me a message and I'll respond!\n" + "Type /help to see available commands.") + + async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /help command, bypassing ACL so all users can access it.""" + if not update.message: + return + await update.message.reply_text(f"{BOT_NAME} commands:\n" + "/new — Start a new conversation\n" + "/stop — Stop the current task\n" + "/help — Show available commands") + + +def repair_telegram_channel(name: str, channel_manager: ChannelManager) -> None: + """Repair the telegram channel. + + Args: + channel_manager: ChannelManager instance. + """ + section = getattr(channel_manager.config.channels, name, None) + if not section: + return + enabled = (section.get("enabled", False) if isinstance(section, dict) else getattr(section, "enabled", False)) + if not enabled: + return + channel_manager.channels[name] = TelegramChannel( + channel_manager.config.channels, + channel_manager.bus, + groq_api_key=channel_manager.config.providers.groq.api_key, + ) + + +register_channel_repair("telegram", repair_telegram_channel) diff --git a/trpc_agent_sdk/server/openclaw/channels/_wecom.py b/trpc_agent_sdk/server/openclaw/channels/_wecom.py new file mode 100644 index 000000000..36af768f6 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/channels/_wecom.py @@ -0,0 +1,96 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# This file is part of tRPC-Agent-Python and is licensed under Apache-2.0. +# +# Portions of this file are derived from HKUDS/nanobot (MIT License): +# https://github.com/HKUDS/nanobot.git +# +# Copyright (c) 2025 nanobot contributors +# +# See the project LICENSE / third-party attribution notices for details. +# +"""WeCom channel patch for proper streaming behavior.""" + +from __future__ import annotations + +from nanobot.bus.events import OutboundMessage +from nanobot.channels.manager import ChannelManager +from nanobot.channels.wecom import WecomChannel as NanobotWecomChannel +from trpc_agent_sdk.log import logger + +from ._repair import register_channel_repair + + +class WecomChannel(NanobotWecomChannel): + """WeCom channel with progress streaming support.""" + + def __init__(self, config, bus): + stream_reply = True + if isinstance(config, dict): + stream_reply = bool(config.get("stream_reply", True)) + else: + stream_reply = bool(getattr(config, "stream_reply", True)) + super().__init__(config, bus) + self._stream_reply = stream_reply + # Correlation key -> stream id + self._active_stream_ids: dict[str, str] = {} + + def _stream_key(self, msg: OutboundMessage) -> str: + message_id = "" + if msg.metadata: + message_id = str(msg.metadata.get("message_id", "") or "") + if message_id: + return f"{msg.chat_id}:{message_id}" + return str(msg.chat_id) + + async def send(self, msg: OutboundMessage) -> None: + """Send message to WeCom with incremental stream chunks.""" + if not self._client: + logger.warning("WeCom client not initialized") + return + + content = (msg.content or "").strip() + if not content: + return + + frame = self._chat_frames.get(msg.chat_id) + if not frame: + logger.warning("No frame found for chat {}, cannot reply", msg.chat_id) + return + + key = self._stream_key(msg) + is_progress = bool((msg.metadata or {}).get("_progress")) + if is_progress and not self._stream_reply: + return + + stream_id = self._active_stream_ids.get(key) + if not stream_id: + stream_id = self._generate_req_id("stream") + self._active_stream_ids[key] = stream_id + + # Progress chunk keeps stream open; final normal message closes it. + await self._client.reply_stream( + frame, + stream_id, + content, + finish=not self._stream_reply, + ) + + if not is_progress: + self._active_stream_ids.pop(key, None) + + +def repair_wecom_channel(name: str, channel_manager: ChannelManager) -> None: + """Replace default WeCom channel with streaming-capable channel.""" + section = getattr(channel_manager.config.channels, name, None) + if not section: + return + enabled = (section.get("enabled", False) if isinstance(section, dict) else getattr(section, "enabled", False)) + if not enabled: + return + channel_manager.channels[name] = WecomChannel(section, channel_manager.bus) + + +register_channel_repair("wecom", repair_wecom_channel) diff --git a/trpc_agent_sdk/server/openclaw/claw.py b/trpc_agent_sdk/server/openclaw/claw.py new file mode 100644 index 000000000..5df292058 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/claw.py @@ -0,0 +1,774 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# This file is part of tRPC-Agent-Python and is licensed under Apache-2.0. +# +# Portions of this file are derived from HKUDS/nanobot (MIT License): +# https://github.com/HKUDS/nanobot.git +# +# Copyright (c) 2025 nanobot contributors +# +# See the project LICENSE / third-party attribution notices for details. +# +"""OpenClaw gateway runner. + +This module aligns OpenClaw runtime behavior with nanobot's AgentLoop gateway: + +- Consume inbound messages from MessageBus (third-party channels) +- Process each message asynchronously via Runner +- Support /stop to cancel in-flight tasks per session +- Route outbound responses back to channels +- Integrate CronService + ClawHeartbeatService callbacks +- Keep long-term / short-term memory wiring (ClawMemoryService / ClawSessionService) + +When no third-party channel is enabled, this runner falls back to a local CLI +loop that still uses the same bus-based processing pipeline. +""" + +from __future__ import annotations + +import asyncio +import uuid +from pathlib import Path +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional + +from dotenv import load_dotenv +from nanobot.bus.events import InboundMessage +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.manager import ChannelManager +from nanobot.cron.types import CronJob +from nanobot.utils.helpers import ensure_dir +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.context import new_agent_context +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import set_summarizer_events_count_threshold +from trpc_agent_sdk.storage import RedisStorage +from trpc_agent_sdk.storage import SqlStorage +from trpc_agent_sdk.types import Content + +from ._logger import default_logger +from ._logger import init_claw_logger +from ._utils import build_user_parts +from ._utils import is_channel_supports_stream_progress +from ._utils import merge_assistant_text +from ._utils import merge_raw_events +from ._utils import parse_origin +from .agent import create_agent +from .agent import create_model +from .channels import TrpcClawCommandHandler +from .channels import TrpcClawCommandHandlerParams +from .channels import repair_channels +from .config import ClawConfig +from .config import DEFAULT_USER_ID +from .config import FileStorageConfig +from .config import load_config +from .metrics import setup_metrics +from .service import ClawHeartbeatService +from .service import CronService +from .session_memory import ClawMemoryService +from .session_memory import ClawSessionService +from .session_memory import ClawSummarizerSessionManager +from .skill import ClawSkillLoader +from .storage import AioFileStorage +from .storage import RAW_EVENTS_KEY +from .storage import StorageManager +from .storage import set_agent_context +from .tools.cron import CRON_CHANNEL_KEY +from .tools.cron import CRON_CHAT_ID_KEY +from .tools.cron import CRON_IN_CONTEXT_KEY +from .tools.message import MESSAGE_CALLBACK_KEY +from .tools.message import MESSAGE_CHANNEL_KEY +from .tools.message import MESSAGE_CHAT_ID_KEY +from .tools.message import MESSAGE_ID_KEY +from .tools.message import MESSAGE_SENT_IN_TURN_KEY +from .tools.spawn_task import SPAWN_TASK_CHANNEL_KEY +from .tools.spawn_task import SPAWN_TASK_CHAT_ID_KEY +from .tools.spawn_task import SPAWN_TASK_SESSION_KEY +from .tools.spawn_task import SPAWN_TASK_SUBMIT_CALLBACK_KEY +from .tools.spawn_task import SPAWN_TASK_USER_ID_KEY + +load_dotenv() + +default_logger() + +_HEARTBEAT_USER_ID = "_system" +_HEARTBEAT_SESSION_ID = "heartbeat" +_CRON_USER_ID = "_cron" + + +class ClawApplication: + """Claw application runtime.""" + + def __init__(self, workspace: Optional[Path] = None, config_path: Optional[Path] = None) -> None: + self.config: ClawConfig = load_config(config_path) + if workspace is not None: + self.config.agent.workspace = str(workspace.expanduser().resolve()) + self.workspace = self.config.workspace + init_claw_logger(self.config.logger) + setup_metrics(self.config) + self.model = create_model(config=self.config) + + # Message bus and channel manager + self.bus = MessageBus() + self.channels = ChannelManager(self.config, self.bus) + + repair_channels(self.channels) + # Memory storage shared by short-term and long-term services + if self.config.storage.type == "sql": + if not self.config.storage.sql: + raise ValueError("Sql storage configuration is required") + config = self.config.storage.sql + self._storage = SqlStorage(is_async=config.is_async, db_url=config.url, **config.kwargs) + elif self.config.storage.type == "redis": + if not self.config.storage.redis: + raise ValueError("Redis storage configuration is required") + config = self.config.storage.redis + self._storage = RedisStorage(is_async=config.is_async, db_url=config.url, **config.kwargs) + else: + memory_dir = ensure_dir(self.workspace / "memory") + config = self.config.storage.file or FileStorageConfig(base_dir=str(memory_dir)) + self._storage = AioFileStorage(config=config) + self._storage_manager = StorageManager(storage=self._storage) + if not self.config.agent.memory_window: + self.config.agent.memory_window = 30 + memory_window = max(30, int(self.config.agent.memory_window)) + + self._summarizer_manager = ClawSummarizerSessionManager( + model=self.model, + storage_manager=self._storage_manager, + auto_summarize=True, + # Align with nanobot trigger: consolidate when unconsolidated + # conversation reaches memory window size. + check_summarizer_functions=[set_summarizer_events_count_threshold(memory_window)], + # Keep half-window recent events (nanobot uses memory_window // 2). + keep_recent_count=max(1, memory_window // 2), + ) + self.session_service = ClawSessionService( + config=self.config, + summarizer_manager=self._summarizer_manager, + ) + memory_service_config = self.config.memory.memory_service_config + memory_service_config.enabled = True + self.memory_service = ClawMemoryService(storage_manager=self._storage_manager, + memory_service_config=memory_service_config) + + # Scheduled services + cron_dir = ensure_dir(self.workspace / "cron") + self.cron_service = CronService(store_path=cron_dir / "jobs.json") + + # Agent + runner + self.agent = create_agent( + config=self.config, + model=self.model, + cron_service=self.cron_service, + ) + self.runner = Runner( + app_name=self.config.runtime.app_name, + agent=self.agent, + session_service=self.session_service, + memory_service=self.memory_service, + ) + worker_agent = self.agent.sub_agents[0] if self.agent.sub_agents else self.agent + self.worker_runner = Runner( + app_name=f"{self.config.runtime.app_name}_worker", + agent=worker_agent, + session_service=self.session_service, + memory_service=self.memory_service, + ) + + hb_cfg = self.config.gateway.heartbeat + self.heartbeat = ClawHeartbeatService( + workspace=self.workspace, + provider=self.model, + model=self.model.name, + on_execute=self._on_heartbeat_execute, + on_notify=self._on_heartbeat_notify, + interval_s=hb_cfg.interval_s, + enabled=hb_cfg.enabled, + ) + + self.cron_service.on_job = self._on_cron_job + + self._running = False + self._inbound_loop_task: asyncio.Task | None = None + self._active_tasks: dict[str, list[asyncio.Task]] = {} + self._background_tasks: dict[str, list[asyncio.Task]] = {} + self._processing_lock = asyncio.Lock() + self._last_external_target: tuple[str, str] = ("cli", "direct") + cmd_params = TrpcClawCommandHandlerParams(config=self.config, + bus=self.bus, + session_service=self.session_service, + active_tasks=self._active_tasks, + background_tasks=self._background_tasks) + self.command_handler = TrpcClawCommandHandler(params=cmd_params) + + # ------------------------------------------------------------------ + # Message processing + # ------------------------------------------------------------------ + + async def _submit_background_task( + self, + *, + task: str, + label: str | None, + origin_channel: str, + origin_chat_id: str, + session_key: str, + user_id: str, + ) -> str: + """Submit a background task without blocking current conversation.""" + task_id = uuid.uuid4().hex[:8] + task_label = (label or task).strip() or "background task" + if len(task_label) > 48: + task_label = task_label[:45] + "..." + + started = (f"Background task '{task_label}' started (id: {task_id}). " + "I will notify you when it completes.") + await self.bus.publish_outbound( + OutboundMessage( + channel=origin_channel, + chat_id=origin_chat_id, + content=started, + metadata={ + "_progress": True, + "_background_task": True, + "_task_id": task_id + }, + )) + + params = { + "session_key": session_key, + "task_id": task_id, + "task": task, + "user_id": user_id, + "origin_channel": origin_channel, + "origin_chat_id": origin_chat_id, + "task_label": task_label + } + task_obj = asyncio.create_task(self._background_task_runner(**params)) + self._background_tasks.setdefault(session_key, []).append(task_obj) + + task_obj.add_done_callback(lambda t, key=session_key: self._cleanup_background_tasks(t, key)) + return started + + async def _background_task_runner(self, session_key: str, task_id: str, task: str, user_id: str, + origin_channel: str, origin_chat_id: str, task_label: str) -> None: + status = "ok" + result = "" + bg_session_id = f"{session_key}:bg:{task_id}" + try: + result = await self._run_turn( + user_id=f"{user_id}_bg", + session_id=bg_session_id, + query=task, + channel=origin_channel, + chat_id=origin_chat_id, + stream_progress=False, + passthrough_metadata={ + "_background_task": True, + "_task_id": task_id + }, + use_worker_agent=True, + ) + if not result: + result = "Task completed." + except asyncio.CancelledError: + status = "cancelled" + result = "Task was cancelled." + raise + except Exception as ex: # pylint: disable=broad-except + status = "error" + result = f"Error: {ex}" + logger.error("Background task %s failed", task_id) + finally: + status_text = ("completed successfully" + if status == "ok" else "was cancelled" if status == "cancelled" else "failed") + summary_prompt = (f"[Background task '{task_label}' {status_text}]\n\n" + f"Task: {task}\n\n" + f"Result:\n{result}\n\n" + "Summarize this naturally for the user. Keep it brief (1-2 sentences). " + "Do not mention internal IDs or implementation details.") + await self.bus.publish_inbound( + InboundMessage( + channel="system", + sender_id="background_task", + chat_id=f"{origin_channel}:{origin_chat_id}", + content=summary_prompt, + metadata={ + "_background_task": True, + "_task_id": task_id, + "_origin_session_key": session_key, + }, + )) + + def _cleanup_background_tasks(self, task: asyncio.Task, key: str) -> None: + tasks = self._background_tasks.get(key, []) + if task in tasks: + tasks.remove(task) + if not tasks and key in self._background_tasks: + del self._background_tasks[key] + + async def _run_turn( + self, + *, + user_id: str, + session_id: str, + query: str, + media: Optional[list[str]] = None, + channel: str, + chat_id: str, + message_id: str | None = None, + stream_progress: bool = True, + progress_callback: Optional[Callable[[str], Awaitable[None] | None]] = None, + in_cron_context: bool = False, + passthrough_metadata: Optional[dict[str, Any]] = None, + use_worker_agent: bool = False, + ) -> str: + """Execute one agent turn and return the final accumulated text.""" + metadata = dict(passthrough_metadata or {}) + metadata.update({ + # Message tool delivery + MESSAGE_CALLBACK_KEY: self.bus.publish_outbound, + MESSAGE_CHANNEL_KEY: channel, + MESSAGE_CHAT_ID_KEY: chat_id, + MESSAGE_ID_KEY: message_id, + # Cron tool delivery + guard + CRON_CHANNEL_KEY: channel, + CRON_CHAT_ID_KEY: chat_id, + CRON_IN_CONTEXT_KEY: in_cron_context, + # Background task dispatch + SPAWN_TASK_SUBMIT_CALLBACK_KEY: self._submit_background_task, + SPAWN_TASK_CHANNEL_KEY: channel, + SPAWN_TASK_CHAT_ID_KEY: chat_id, + SPAWN_TASK_SESSION_KEY: session_id, + SPAWN_TASK_USER_ID_KEY: user_id, + # Turn flag + MESSAGE_SENT_IN_TURN_KEY: False, + }) + + agent_context = new_agent_context(metadata=metadata) + content = Content(parts=build_user_parts(query=query, media=media)) + + final_text = "" + + runner = self.worker_runner if use_worker_agent else self.runner + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=content, + agent_context=agent_context, + ): + if not event.content or not event.content.parts: + continue + + # Partial events are streaming deltas; they should never be mixed into + # final text aggregation. Optionally forward them as progress updates. + if event.partial: + if stream_progress: + chunk = "".join(part.text for part in event.content.parts if part.text and not part.thought) + if chunk: + if progress_callback is not None: + result = progress_callback(chunk) + if result is not None: + await result + else: + progress_meta = dict(passthrough_metadata or {}) + progress_meta["_progress"] = True + await self.bus.publish_outbound( + OutboundMessage( + channel=channel, + chat_id=chat_id, + content=chunk, + metadata=progress_meta, + )) + continue + + has_function_call = False + + # Emit tool hints during non-partial events when function calls appear + if self.config.channels.send_tool_hints: + for part in event.content.parts: + if part.function_call: + has_function_call = True + hint_meta = dict(passthrough_metadata or {}) + hint_meta["_progress"] = True + hint_meta["_tool_hint"] = True + await self.bus.publish_outbound( + OutboundMessage( + channel=channel, + chat_id=chat_id, + content=f"{part.function_call.name}({part.function_call.args})", + metadata=hint_meta, + )) + else: + has_function_call = any(part.function_call for part in event.content.parts) + + # Skip text from events that contain tool calls; these are often + # pre-tool drafts and can cause duplicated final answers. + if has_function_call: + continue + + event_text = "".join(part.text for part in event.content.parts if part.text and not part.thought) + if event_text: + final_text = merge_assistant_text(final_text, event_text) + + # If the message tool already sent the user-facing reply, do not duplicate. + await self._persist_session_after_turn( + app_name=self.config.runtime.app_name, + user_id=user_id, + session_id=session_id, + agent_context=agent_context, + ) + if agent_context.get_metadata(MESSAGE_SENT_IN_TURN_KEY, False): + return "" + + return final_text + + async def _persist_session_after_turn( + self, + *, + app_name: str, + user_id: str, + session_id: str, + agent_context: AgentContext, + ) -> None: + """Persist session snapshot after each completed turn. + + This avoids losing unsummarized raw events on unexpected process exit. + """ + try: + session = await self.session_service.get_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + agent_context=agent_context, + ) + if session is None: + return + # Keep full raw archive durable even when summarization does not run. + existing_raw = agent_context.get_metadata(RAW_EVENTS_KEY, []) or [] + merged_raw = merge_raw_events(existing_raw, list(session.events)) + agent_context.with_metadata(RAW_EVENTS_KEY, merged_raw) + set_agent_context(agent_context) + await self.session_service.update_session(session) + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to persist session after turn app=%s user=%s session=%s: %s", + app_name, + user_id, + session_id, + ex, + ) + + async def _process_message( + self, + msg: InboundMessage, + *, + stream_progress_override: Optional[bool] = None, + progress_callback: Optional[Callable[[str], Awaitable[None] | None]] = None, + ) -> Optional[OutboundMessage]: + """Process one inbound message and return an outbound response.""" + self._refresh_skill_repository() + channel, chat_id = parse_origin(msg) + + if channel not in {"cli", "system"}: + self._last_external_target = (channel, chat_id) + + user_id = msg.sender_id or f"{channel}_{DEFAULT_USER_ID}" + session_id = msg.session_key + query = msg.content + + # Progress strategy by channel: + # - CLI: use dedicated callback streaming in run_cli_fallback. + # - Telegram/WeCom: disable bus-level progress chunks to avoid message spam. + # These channels already provide their own better UX reply behavior. + is_stream_progress_enabled = is_channel_supports_stream_progress(channel) + stream_progress = (stream_progress_override if stream_progress_override else + (self.config.channels.send_progress and is_stream_progress_enabled)) + response_text = await self._run_turn( + user_id=user_id, + session_id=session_id, + query=query, + media=msg.media if msg.media else None, + channel=channel, + chat_id=chat_id, + message_id=msg.metadata.get("message_id"), + stream_progress=stream_progress, + progress_callback=progress_callback, + passthrough_metadata=msg.metadata, + ) + + if not response_text: + return None + return OutboundMessage( + channel=channel, + chat_id=chat_id, + content=response_text, + metadata=msg.metadata, + ) + + def _refresh_skill_repository(self) -> None: + """Refresh skill repository index before each turn.""" + repository = getattr(self.agent, "skill_repository", None) + if not isinstance(repository, ClawSkillLoader): + return + repository.refresh() + + async def _dispatch(self, msg: InboundMessage) -> None: + """Process inbound message under lock and publish output.""" + async with self._processing_lock: + try: + response = await self._process_message(msg) + if response is not None: + await self.bus.publish_outbound(response) + elif msg.channel == "cli": + await self.bus.publish_outbound( + OutboundMessage( + channel="cli", + chat_id=msg.chat_id, + content="", + metadata=msg.metadata or {}, + )) + except asyncio.CancelledError: + logger.info("Task cancelled for session %s", msg.session_key) + raise + except Exception as ex: + logger.error("Error processing message for session %s: %s", msg.session_key, ex) + await self.bus.publish_outbound( + OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="Sorry, I encountered an error.", + )) + + async def _inbound_loop(self) -> None: + """Consume bus inbound queue and dispatch messages asynchronously.""" + logger.info("Inbound loop started") + while self._running: + try: + msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) + except asyncio.TimeoutError: + continue + is_handled = await self.command_handler.handle(msg) + if is_handled: + continue + + task = asyncio.create_task(self._dispatch(msg)) + key = msg.session_key + self._active_tasks.setdefault(key, []).append(task) + + def _cleanup(t: asyncio.Task, session_key: str = key) -> None: + tasks = self._active_tasks.get(session_key, []) + if t in tasks: + tasks.remove(t) + if not tasks and session_key in self._active_tasks: + del self._active_tasks[session_key] + + task.add_done_callback(_cleanup) + + # ------------------------------------------------------------------ + # Heartbeat + Cron callbacks + # ------------------------------------------------------------------ + async def _on_heartbeat_execute(self, tasks: str) -> str: + """Execute heartbeat tasks through the full agent loop.""" + channel, chat_id = self._last_external_target + logger.info("Heartbeat executing tasks on %s:%s", channel, chat_id) + return await self._run_turn( + user_id=_HEARTBEAT_USER_ID, + session_id=_HEARTBEAT_SESSION_ID, + query=tasks, + channel=channel, + chat_id=chat_id, + stream_progress=False, + passthrough_metadata={"_heartbeat": True}, + ) + + async def _on_heartbeat_notify(self, response: str) -> None: + """Deliver heartbeat response to user channel (skip pure CLI fallback).""" + channel, chat_id = self._last_external_target + if channel == "cli": + logger.info("Heartbeat response (cli): %s", response) + return + await self.bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) + + async def _on_cron_job(self, job: CronJob) -> str | None: + """Execute cron job through the same runner pipeline.""" + if not job.payload.message: + logger.warning("Cron job {!r} has empty message", job.name) + return None + + channel = job.payload.channel or "cli" + chat_id = job.payload.to or "direct" + reminder = ("[Scheduled Task] Timer finished.\n\n" + f"Task '{job.name}' has been triggered.\n" + f"Scheduled instruction: {job.payload.message}") + + result = await self._run_turn( + user_id=_CRON_USER_ID, + session_id=f"cron:{job.id}", + query=reminder, + channel=channel, + chat_id=chat_id, + stream_progress=False, + in_cron_context=True, + passthrough_metadata={"_cron": True}, + ) + + if job.payload.deliver and job.payload.to and result: + await self.bus.publish_outbound(OutboundMessage( + channel=channel, + chat_id=chat_id, + content=result, + )) + + return result + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def start(self) -> None: + """Start gateway runtime: cron, heartbeat, channel manager, inbound loop.""" + if self._running: + return + self._running = True + + await self.cron_service.start() + await self.heartbeat.start() + self._inbound_loop_task = asyncio.create_task(self._inbound_loop()) + + enabled = self.channels.enabled_channels + if enabled: + logger.info("Channels enabled: %s", ", ".join(enabled)) + else: + logger.warning("No third-party channels enabled; using CLI fallback mode") + + cron_status = self.cron_service.status() + if cron_status.get("jobs", 0) > 0: + logger.info("Cron jobs loaded: %s", cron_status["jobs"]) + + logger.info("Claw gateway started workspace= %s", self.workspace) + + async def stop(self) -> None: + """Stop gateway runtime gracefully.""" + if not self._running: + return + self._running = False + + if self._inbound_loop_task: + self._inbound_loop_task.cancel() + await asyncio.gather(self._inbound_loop_task, return_exceptions=True) + self._inbound_loop_task = None + + # cancel active message tasks + all_tasks = [t for tasks in self._active_tasks.values() for t in tasks] + for t in all_tasks: + if not t.done(): + t.cancel() + if all_tasks: + await asyncio.gather(*all_tasks, return_exceptions=True) + self._active_tasks.clear() + + # cancel background tasks + all_bg_tasks = [t for tasks in self._background_tasks.values() for t in tasks] + for t in all_bg_tasks: + if not t.done(): + t.cancel() + if all_bg_tasks: + await asyncio.gather(*all_bg_tasks, return_exceptions=True) + self._background_tasks.clear() + + self.heartbeat.stop() + self.cron_service.stop() + await self.channels.stop_all() + logger.info("Claw gateway stopped") + + # ------------------------------------------------------------------ + # Entry runners + # ------------------------------------------------------------------ + + async def run_gateway(self) -> None: + """Run with third-party channels (or none, if disabled in config).""" + await self.start() + try: + await asyncio.gather( + self.channels.start_all(), + self._wait_forever(), + ) + finally: + await self.stop() + + async def run_cli_fallback(self) -> None: + """CLI fallback that still goes through MessageBus + inbound loop.""" + await self.start() + loop = asyncio.get_event_loop() + + print(f"Claw is ready. workspace={self.workspace}") + print("Commands: /new, /stop, /help, /quit") + + try: + while True: + try: + raw = await loop.run_in_executor(None, lambda: input("You: ")) + except (EOFError, KeyboardInterrupt): + print() + break + + query = raw.strip() + if not query: + continue + if query in {"/quit", "/exit", "quit", "exit"}: + break + + # Process synchronously in CLI mode so prompt/output ordering is stable: + # one "You:" input -> one "Assistant:" output. + msg = InboundMessage( + channel="cli", + sender_id=DEFAULT_USER_ID, + chat_id="direct", + content=query, + ) + streamed = False + printed_header = False + + async def _cli_progress(chunk: str) -> None: + nonlocal streamed, printed_header + if not printed_header: + print("\nAssistant: ", end="", flush=True) + printed_header = True + streamed = True + print(chunk, end="", flush=True) + + async with self._processing_lock: + is_handled = await self.command_handler.handle(msg) + if is_handled: + while True: + try: + outbound = self.bus.outbound.get_nowait() + except asyncio.QueueEmpty: + break + if outbound.channel == "cli" and outbound.content: + print(f"\nAssistant: {outbound.content}") + continue + response = await self._process_message( + msg, + stream_progress_override=True, + progress_callback=_cli_progress, + ) + + if streamed: + print() + elif response is not None and response.content: + print(f"\nAssistant: {response.content}") + finally: + await self.stop() + + async def _wait_forever(self) -> None: + while self._running: + await asyncio.sleep(1) diff --git a/trpc_agent_sdk/server/openclaw/config.temp.yaml b/trpc_agent_sdk/server/openclaw/config.temp.yaml new file mode 100644 index 000000000..da6e86e1d --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/config.temp.yaml @@ -0,0 +1,27 @@ +runtime: + app_name: trpc_claw_py + user_id: trpc_claw_user +agent: + # workspace: ~/.trpc_claw/workspace + memory_window: 30 + api_key: ${TRPC_AGENT_API_KEY} + api_base: ${TRPC_AGENT_BASE_URL} + model: ${TRPC_AGENT_MODEL_NAME} +channels: + wecom: + stream_reply: true + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + allow_from: ["*"] + welcome_message: "" + +gateway: + host: 0.0.0.0 + port: 18790 + heartbeat: + enabled: true + interval_s: 1800 + +# skills: +# sandbox_type: container diff --git a/trpc_agent_sdk/server/openclaw/config/__init__.py b/trpc_agent_sdk/server/openclaw/config/__init__.py new file mode 100644 index 000000000..88bc23746 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/config/__init__.py @@ -0,0 +1,64 @@ +# 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. +"""Config module for trpc_claw.""" + +from ._config import ClawConfig +from ._config import ContainerCodeExecutorConfig +from ._config import FileStorageConfig +from ._config import LocalCodeExecutorConfig +from ._config import LoggerConfig +from ._config import MetricsConfig +from ._config import RedisStorageConfig +from ._config import RuntimeConfig +from ._config import SkillConfig +from ._config import SkillRootConfig +from ._config import SqlStorageConfig +from ._config import StorageConfig +from ._config import load_config +from ._constants import AGENT_FILE_NAME +from ._constants import BOT_NAME +from ._constants import DEFAULT_APP_NAME +from ._constants import DEFAULT_CONFIG_PATH +from ._constants import DEFAULT_LEGACY_SESSIONS_DIR +from ._constants import DEFAULT_TRPC_CLAW_DIR +from ._constants import DEFAULT_USER_ID +from ._constants import DEFAULT_WORKSPACE_PATH +from ._constants import HISTORY_FILE_NAME +from ._constants import MEMORY_FILE_NAME +from ._constants import SOUL_FILE_NAME +from ._constants import TOOL_FILE_NAME +from ._constants import TRPC_CLAW_SKILLS_INSTALL_ROOT_ENV_NAME +from ._constants import USER_FILE_NAME + +__all__ = [ + "ClawConfig", + "RuntimeConfig", + "load_config", + "LocalCodeExecutorConfig", + "ContainerCodeExecutorConfig", + "SkillConfig", + "SkillRootConfig", + "FileStorageConfig", + "SqlStorageConfig", + "RedisStorageConfig", + "StorageConfig", + "BOT_NAME", + "DEFAULT_TRPC_CLAW_DIR", + "DEFAULT_APP_NAME", + "DEFAULT_USER_ID", + "DEFAULT_CONFIG_PATH", + "DEFAULT_WORKSPACE_PATH", + "DEFAULT_LEGACY_SESSIONS_DIR", + "LoggerConfig", + "TRPC_CLAW_SKILLS_INSTALL_ROOT_ENV_NAME", + "HISTORY_FILE_NAME", + "MEMORY_FILE_NAME", + "SOUL_FILE_NAME", + "USER_FILE_NAME", + "TOOL_FILE_NAME", + "AGENT_FILE_NAME", + "MetricsConfig", +] diff --git a/trpc_agent_sdk/server/openclaw/config/_config.py b/trpc_agent_sdk/server/openclaw/config/_config.py new file mode 100644 index 000000000..87884a693 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/config/_config.py @@ -0,0 +1,363 @@ +# 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. +"""trpc_claw configuration loader. + +This module provides a config object that: +1. loads from YAML/JSON file +2. applies environment-variable overrides +3. exposes nanobot-compatible config sections used by channel manager/runtime +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any +from typing import Optional + +import yaml +from nanobot.config.loader import set_config_path +from nanobot.config.schema import AgentDefaults +from nanobot.config.schema import Config as NanobotConfig +from nanobot.utils.helpers import ensure_dir +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from trpc_agent_sdk.abc import MemoryServiceConfig +from trpc_agent_sdk.server.langfuse.tracing.opentelemetry import LangfuseConfig + +from ._constants import AGENT_FILE_NAME +from ._constants import DEFAULT_APP_NAME +from ._constants import DEFAULT_CONFIG_PATH +from ._constants import DEFAULT_LEGACY_SESSIONS_DIR +from ._constants import DEFAULT_TRPC_CLAW_DIR +from ._constants import DEFAULT_USER_ID +from ._constants import DEFAULT_WORKSPACE_PATH +from ._constants import HISTORY_FILE_NAME +from ._constants import MEMORY_FILE_NAME +from ._constants import SOUL_FILE_NAME +from ._constants import TOOL_FILE_NAME +from ._constants import TRPC_CLAW_CONFIG +from ._constants import USER_FILE_NAME + + +class LocalCodeExecutorConfig(BaseModel): + """trpc_claw local code executor config.""" + workspace: str = "" + read_only_staged_skill: bool = False + auto_inputs: bool = True + inputs_host_base: str = "" + + +class ContainerCodeExecutorConfig(BaseModel): + """trpc_claw container code executor config.""" + base_url: Optional[str] = None + """The base url of the user hosted Docker client.""" + image: str = "python:3-slim" + """The tag of the predefined image or custom image to run on the container. + Either docker_path or image must be set. + """ + docker_path: Optional[str] = None + """The path to the directory containing the Dockerfile. + If set, build the image from the dockerfile path instead of using the + predefined image. Either docker_path or image must be set. + """ + auto_inputs: bool = False + """Whether to auto-map inputs.""" + inputs_host_base: str = "" + + +class SkillConfig(BaseModel): + """Per-skill runtime config.""" + enabled: Optional[bool] = None + env: dict[str, str] = Field(default_factory=dict) + + +class SkillRootConfig(BaseModel): + """trpc_claw skill root config.""" + sandbox_type: str = "local" + skill_roots: list[str] = Field(default_factory=list) + builtin_skill_roots: list[str] = Field(default_factory=list) + config_keys: list[str] = Field(default_factory=list) + allow_bundled: list[str] = Field(default_factory=list) + skill_configs: dict[str, SkillConfig] = Field(default_factory=dict) + local_config: LocalCodeExecutorConfig = Field(default_factory=LocalCodeExecutorConfig) + container_config: ContainerCodeExecutorConfig = Field(default_factory=ContainerCodeExecutorConfig) + run_tool_kwargs: dict[str, Any] = Field(default_factory=dict) + debug: bool = False + """The debug mode.""" + bundled_root: str = "" + """The bundled root.""" + + +class RuntimeConfig(BaseModel): + """trpc_claw runtime-only config (not part of nanobot schema).""" + + app_name: str = DEFAULT_APP_NAME + user_id: str = DEFAULT_USER_ID + legacy_sessions_dir: str = str(DEFAULT_LEGACY_SESSIONS_DIR) + + +class AgentConfig(AgentDefaults): + """trpc_claw agent config.""" + instruction: str = "" + system_prompt: str = "" + api_key: str = "" + api_base: str = "" + extra_headers: dict[str, str] = Field(default_factory=dict) + memory_window: int = Field(default=30, ge=30, le=10000) + + +class MemoryConfig(BaseModel): + """trpc_claw memory config.""" + memory_service_config: MemoryServiceConfig = Field(default_factory=MemoryServiceConfig) + + +class FileStorageConfig(BaseModel): + """trpc_claw file storage config.""" + base_dir: str = "" + max_key_length: int = 255 + + +class SqlStorageConfig(BaseModel): + """trpc_claw sql storage config.""" + url: str = "" + is_async: bool = False + kwargs: dict[str, Any] = Field(default_factory=dict) + + +class RedisStorageConfig(BaseModel): + """trpc_claw redis storage config.""" + url: str = "" + is_async: bool = False + password: str = "" + db: int = 0 + kwargs: dict[str, Any] = Field(default_factory=dict) + + +class StorageConfig(BaseModel): + """trpc_claw storage config.""" + model_config = ConfigDict(extra="forbid", ) + """The pydantic model config.""" + type: str = "file" + """The storage type.""" + file: Optional[FileStorageConfig] = None + redis: Optional[RedisStorageConfig] = None + sql: Optional[SqlStorageConfig] = None + + +class LoggerConfig(BaseModel): + """trpc_claw logger config.""" + name: str = "trpc_claw" + """The logger name.""" + log_file: str = "trpc_claw.log" + """The log file.""" + log_level: str = "INFO" + """The log level.""" + log_format: str = "[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s" + + +class MetricsConfig(BaseModel): + """trpc_claw metrics config.""" + type: str = "langfuse" + """The metrics type.""" + langfuse: LangfuseConfig = Field(default_factory=LangfuseConfig) + + +class ClawConfig(NanobotConfig): + """Root trpc_claw config. + + Fields are intentionally aligned with nanobot's top-level config sections + so existing channel/cron/heartbeat code can reuse the same access pattern. + """ + + agent: AgentConfig = Field(default_factory=AgentConfig) + skills: SkillRootConfig = Field(default_factory=SkillRootConfig) + memory: MemoryConfig = Field(default_factory=MemoryConfig) + storage: StorageConfig = Field(default_factory=StorageConfig) + runtime: RuntimeConfig = Field(default_factory=RuntimeConfig) + logger: LoggerConfig = Field(default_factory=LoggerConfig) + personal: list[str] = Field(default_factory=list) + metrics: MetricsConfig = Field(default_factory=MetricsConfig) + + @property + def workspace(self) -> Path: + """Resolved workspace path. + + Returns: + Path: The resolved workspace path. + """ + return Path(self.agent.workspace).expanduser().resolve() + + @property + def model_name(self) -> str: + """Resolved model name. + + Returns: + str: The resolved model name. + """ + return self.agent.model + + @property + def model_api_key(self) -> str: + """Resolved model API key. + + Returns: + str: The resolved model API key. + """ + return self.agent.api_key + + @property + def model_base_url(self) -> str: + """Resolved model base URL. + + Returns: + str: The resolved model base URL. + """ + return self.agent.api_base + + @property + def model_extra_headers(self) -> dict[str, str]: + """Resolved model extra headers. + + Returns: + dict[str, str]: The resolved model extra headers. + """ + return self.agent.extra_headers + + @property + def skill_roots(self) -> str: + """Resolved skill roots. + + Returns: + list[str]: The resolved skill roots. + """ + return self.skills.skill_roots + + +def _read_config_file(path: Path) -> dict[str, Any]: + """Read YAML/JSON config file into a dict. + + Args: + path: The path to the config file. + + Returns: + dict[str, Any]: The config file as a dictionary. + """ + if not path.exists(): + return {} + + if path.suffix.lower() in {".yaml", ".yml"}: + if yaml is None: + raise ValueError("PyYAML is required to load YAML config files") + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError(f"Invalid YAML config format: {path}") + return data + + with open(path, encoding="utf-8") as f: + data = json.load(f) or {} + if not isinstance(data, dict): + raise ValueError(f"Invalid JSON config format: {path}") + return data + + +def _expand_env_vars(value: Any) -> Any: + """Recursively expand environment variables for config values. + + Args: + value: The value to expand. + + Returns: + Any: The expanded value. + """ + if isinstance(value, str): + return os.path.expandvars(value) + if isinstance(value, list): + return [_expand_env_vars(item) for item in value] + if isinstance(value, dict): + return {k: _expand_env_vars(v) for k, v in value.items()} + return value + + +def create_inner_dirs_and_files(config: ClawConfig): + """Create inner dirs for the config.""" + ensure_dir(config.workspace) + # sessions + ensure_dir(config.workspace / "sessions") + soul_file = config.workspace / SOUL_FILE_NAME + soul_file.touch(exist_ok=True) + user_file = config.workspace / USER_FILE_NAME + user_file.touch(exist_ok=True) + tool_file = config.workspace / TOOL_FILE_NAME + tool_file.touch(exist_ok=True) + agent_file = config.workspace / AGENT_FILE_NAME + agent_file.touch(exist_ok=True) + # memory + ensure_dir(config.workspace / "memory") + history_file = config.workspace / "memory" / HISTORY_FILE_NAME + history_file.touch(exist_ok=True) + memory_file = config.workspace / "memory" / MEMORY_FILE_NAME + memory_file.touch(exist_ok=True) + # skills + ensure_dir(config.workspace / "skills") + # skills workspace + ensure_dir(Path(config.skills.local_config.workspace)) + + +def load_config(config_path: Optional[Path] = None) -> ClawConfig: + """Load config from YAML/JSON file then apply env overrides. + + Search order: + 1) explicit config_path + 2) ``$CLAW_CONFIG`` + 3) ``DEFAULT_CONFIG_PATH`` (json) + 4) sibling yaml: ``config.yaml`` / ``config.yml`` (if present) + """ + if not config_path: + config_path = os.getenv(TRPC_CLAW_CONFIG, "").strip() + if not config_path: + if not DEFAULT_TRPC_CLAW_DIR.exists(): + DEFAULT_TRPC_CLAW_DIR.mkdir(parents=True, exist_ok=True) + config_path = str(DEFAULT_CONFIG_PATH) + + path = Path(config_path) + set_config_path(config_path) + + raw = _read_config_file(path) + if raw: + raw = _expand_env_vars(raw) + # Backward compatibility: accept legacy top-level "agents" key. + if "agent" not in raw and "agents" in raw and isinstance(raw["agents"], dict): + raw["agent"] = raw.pop("agents") + need_default_workspace = False + if "agent" not in raw or "workspace" not in raw["agent"]: + need_default_workspace = True + cfg = ClawConfig.model_validate(raw) if raw else ClawConfig() + if need_default_workspace: + cfg.agent.workspace = str(DEFAULT_WORKSPACE_PATH) + if not cfg.agent.api_key: + cfg.agent.api_key = os.getenv("TRPC_AGENT_API_KEY", "") + if not cfg.agent.api_base: + cfg.agent.api_base = os.getenv("TRPC_AGENT_BASE_URL", "") + if not cfg.agent.model or "agent" not in raw: + cfg.agent.model = os.getenv("TRPC_AGENT_MODEL_NAME", "") + telegram = getattr(cfg.channels, "telegram", {}) + if telegram: + if 'token' not in telegram or not telegram['token']: + telegram['token'] = os.getenv("TELEGRAM_BOT_TOKEN", "") + wecom = getattr(cfg.channels, "wecom", {}) + if wecom: + if 'bot_id' not in wecom or not wecom['bot_id']: + wecom['bot_id'] = os.getenv("WECOM_BOT_ID", "") + if 'secret' not in wecom or not wecom['secret']: + wecom['secret'] = os.getenv("WECOM_BOT_SECRET", "") + if not cfg.skills.local_config.workspace: + cfg.skills.local_config.workspace = f"{cfg.agent.workspace}/skills_ws" + create_inner_dirs_and_files(cfg) + return cfg diff --git a/trpc_agent_sdk/server/openclaw/config/_constants.py b/trpc_agent_sdk/server/openclaw/config/_constants.py new file mode 100644 index 000000000..eb1f31330 --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/config/_constants.py @@ -0,0 +1,42 @@ +# 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. +"""Constants for trpc_claw.""" + +from pathlib import Path + +BOT_NAME: str = "trpc_claw" +"""Bot name.""" +DEFAULT_TRPC_CLAW_DIR: Path = Path.home() / ".trpc_claw" +"""Default trpc_claw directory.""" +DEFAULT_CONFIG_PATH: Path = DEFAULT_TRPC_CLAW_DIR / "config.yaml" +"""Default config path for trpc_claw.""" +DEFAULT_WORKSPACE_PATH: Path = DEFAULT_TRPC_CLAW_DIR / "workspace" +"""Default workspace path for trpc_claw.""" +DEFAULT_LEGACY_SESSIONS_DIR: Path = DEFAULT_TRPC_CLAW_DIR / "sessions" +"""Legacy sessions directory for trpc_claw.""" +TRPC_CLAW_CONFIG: str = "TRPC_CLAW_CONFIG" +"""TRPC_CLAW_CONFIG environment variable for trpc_claw.""" + +DEFAULT_APP_NAME: str = "trpc_claw_py" +"""Default app name for trpc_claw.""" +DEFAULT_USER_ID: str = "trpc_claw_user" +"""Default user id for trpc_claw.""" + +TRPC_CLAW_SKILLS_INSTALL_ROOT_ENV_NAME: str = "TRPC_CLAW_SKILLS_INSTALL_ROOT" +"""TRPC_CLAW_SKILLS_INSTALL_ROOT environment variable for trpc_claw.""" + +HISTORY_FILE_NAME: str = "HISTORY.md" +"""History file name for trpc_claw.""" +MEMORY_FILE_NAME: str = "MEMORY.md" +"""Memory file name for trpc_claw.""" +SOUL_FILE_NAME: str = "SOUL.md" +"""Soul file name for trpc_claw.""" +USER_FILE_NAME: str = "USER.md" +"""User file name for trpc_claw.""" +TOOL_FILE_NAME: str = "TOOLS.md" +"""Tools file name for trpc_claw.""" +AGENT_FILE_NAME: str = "AGENTS.md" +"""Agent file name for trpc_claw.""" diff --git a/trpc_agent_sdk/server/openclaw/config_full.temp.yaml b/trpc_agent_sdk/server/openclaw/config_full.temp.yaml new file mode 100644 index 000000000..26f101fdd --- /dev/null +++ b/trpc_agent_sdk/server/openclaw/config_full.temp.yaml @@ -0,0 +1,169 @@ +# 完整配置模板(对应 trpc_agent_sdk/server/openclaw/config/_config.py) + +runtime: + # 默认来自 DEFAULT_APP_NAME / DEFAULT_USER_ID + app_name: trpc_claw_py + user_id: trpc_claw_user + # 兼容历史 session 目录 + legacy_sessions_dir: ~/.trpc_claw/sessions + +agent: + # 默认工作目录(不填会自动使用 ~/.trpc_claw/workspace) + workspace: ~/.trpc_claw/workspace + model: ${TRPC_AGENT_MODEL_NAME} + provider: auto + max_tokens: 8192 + context_window_tokens: 65536 + temperature: 0.1 + max_tool_iterations: 40 + memory_window: 30 + reasoning_effort: null + instruction: "" + system_prompt: "" + api_key: ${TRPC_AGENT_API_KEY} + api_base: ${TRPC_AGENT_BASE_URL} + extra_headers: {} + +channels: + send_progress: true + send_tool_hints: false + + # 可选:WhatsApp bridge + whatsapp: + enabled: false + bridge_url: ws://localhost:3001 + bridge_token: "" + allow_from: [] + + telegram: + enabled: false + token: ${TELEGRAM_BOT_TOKEN} + proxy: null + allow_from: ["*"] + reply_to_message: false + + wecom: + enabled: true + bot_id: ${WECOM_BOT_ID} + secret: ${WECOM_BOT_SECRET} + allow_from: ["*"] + welcome_message: "" + stream_reply: true + restart_command: /restart + +gateway: + host: 0.0.0.0 + port: 18790 + heartbeat: + enabled: true + interval_s: 1800 + +tools: + web: + proxy: null + search: + provider: brave + api_key: "" + base_url: "" + max_results: 5 + exec: + timeout: 60 + path_append: "" + restrict_to_workspace: false + mcp_servers: {} + # mcp_servers: + # my_server: + # type: stdio + # command: npx + # args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + # env: {} + # url: "" + # headers: {} + # tool_timeout: 30 + # enabled_tools: ["*"] + +skills: + sandbox_type: local # local | container | pcg123 + # 支持多个 root:本地目录 / file:// / http(s):// 技能包 + skill_roots: [] + # 示例: + # skill_roots: + # - /data/xxx/python-math + # - file:///tmp/test_skill.zip + # - http://127.0.0.1:8088/archive/test_skill.zip + # - file:///data/xxx/skill_dir + builtin_skill_roots: [] + # 用于满足 skill 元数据中的 requires.config 检查 + config_keys: + # 示例1:允许一个逻辑开关(可被 SKILL.md 的 requires.config 命中) + - skillhub.enabled + # 示例2:允许某个命名空间(has_config_key 支持前缀匹配) + - skillhub + # 当仅允许部分内置 skill 暴露时可配置白名单(skill_key 或 name) + allow_bundled: + # 示例:skill-finder 在 SKILL.md 中声明了 skill_key = skill.finder + # 优先按 skill_key 命中,也可补充 name 作为兼容 + - skillhub.skill.finder + - skillhub-skill-finder + # 按 skill_key 或 name 配置启停和专属环境变量 + skill_configs: + skillhub-skill-finder: + enabled: true + env: + SKILLHUB_TOKEN: "${SKILLHUB_TOKEN}" + SKILLHUB_USERNAME: ${SKILLHUB_USERNAME} + run_tool_kwargs: {} + + local_config: + workspace: "" + read_only_staged_skill: false + auto_inputs: true + inputs_host_base: "" + + container_config: + base_url: null + image: python:3-slim + docker_path: null + auto_inputs: false + inputs_host_base: "" + +logger: + name: trpc_claw + log_file: trpc_claw.log + log_level: INFO + log_format: "[%(asctime)s][%(levelname)s][%(name)s][%(pathname)s:%(lineno)d][%(process)d] %(message)s" + +# 指定个人文件路径(可选),用于替换默认提示词文件 +personal: + soul_file: "" # 必须以 SOUL.md 结尾,用于设置用户人格相关的信息,作为提示词的一部分,可以修改 + user_file: "" # 必须以 USER.md 结尾,用于设置用户个人相关的信息,作为提示词的一部分,可以修改 + tool_file: "" # 必须以 TOOLS.md 结尾,用于设置工具相关的信息,作为提示词的一部分, 谨慎修改,如无必要,请勿修改 + agent_file: "" # 必须以 AGENTS.md 结尾,用于设置代理相关的信息,作为提示词的一部分, 谨慎修改,如无必要,请勿修改 + +memory: + memory_service_config: + enabled: true + ttl: + enable: false + ttl_seconds: 0 + cleanup_interval_seconds: 0.0 + update_time: 0.0 + +storage: + # file | redis | sql + type: file + file: + # 空字符串时运行时会自动回退到 workspace/memory + base_dir: "" + max_key_length: 255 + redis: + url: "" + is_async: false + password: "" + db: 0 + kwargs: {} + sql: + url: "" + is_async: false + kwargs: {} + diff --git a/trpc_agent_sdk/server/openclaw/image/telegram.png b/trpc_agent_sdk/server/openclaw/image/telegram.png new file mode 100644 index 0000000000000000000000000000000000000000..6f45ef0d2d0c37486c2e6c1c9cf19f250b914edd GIT binary patch literal 1590508 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%0Acr8I#4pTxmzSoDwVUWFc3uESRN5f!Ubq zNVULekVc@6v@T{XcQ*=L`YqI9CngECJ%NTfbnKARB~PIso?L|?WAP_L;oW5Wo#)?V zDh51_))104Kc#%IbeJH^xUYV?57CyjUrrf!&W6Do-v>)A#|I~$`SfA%PAle+URs>> z#iz!XHl5vcL;2#4>)qRtxNJ#cJLzT(w_rl#98=xhM+_Z&d!LPZ+(i#g|0sk0SjzcQ zha!8}PoIiym|WjPcEVN_BThc}?d$akl}OB51|v^V=s{;Fmwu?Wqm}m~HOzht8gp|*sA|@%I(BX{frai1s^W!SLLGv2G%GsTsgkdd=%T%qlb%Oo#~M0`a=`oaxY>M z0EbqK0@L4dbyAsYnY>e{D2OaVE|KSJx?edzOZ2acYUHMyHfNh}V7NQEir&Zg7uz_J zU#$uS;iIvws4IWT%;UUATN0a#*RE@8gQVZ1%d)! zbv%%5K~`V$no?8znpVucMXMYe3J<4O_Mi3`AJk5LyTmlUNX?hd= zT6~nrolpQoMV4`NaE~q2+`nVNRugg-Ic@u|+c<>Y4^Ki>21mp%=j!=hoJ4k!}#u58EW6M}T5yKO%M=KQ`9rEz*4h!r@wS zj{8lc=#E}_o%>_W+*&Avy`AGnwJeAA>tuBC$YfCNbDdVp={ZH>zKQY3{es$4N&(>M zXuW4r*=oAz^iNdJqg${Z$xh6R|dxkM1cd1I^R%#K0w{D3@m((5c*Dqet~@kkt|{CNFLVM ztH5yzHur1Hv3=IE8x~@G(~{1$j$(3nH5{v?pF!L_C0HHdl-_M{;{0&&u)ibw+_Y_( zWzTW0`pce=fC9$Uf{`Z|J4x~*?l*HxSrL$V}c0jQwR=?CoC)z*T*x52?7oMoH^v*iddH%NFI$sjq+U#qq*SA{fT?WQrX zvyi~RvW8%c+WQXpEk{W@yGY#IET^#J1%qV1bOxP1Z)huSwy+`DmA5vjPh<&Lb)7RK z@{V(X-7eg$DuLia#Q-?i%>*jM83*WFTggUc=y;Su>gt?)itBxTrkUB(0L6%;6M2?F zJ4+UR|1^sC#uoPZtQ}iL$lIf^cWv~;-<35-847p&DPM>t(5m?ozBmBiQol>ronP}N82FgF0!jJ5Qo&^Ko2W43Pv~R^f zn1!muhB~5-#f6u_AeJ9j^$NPWjG`NRR4QqOp;FP9HFzVB3@cg|AN@-1v|U@rWZ_Ig2g-ql6$h?o}= zW%-Fu&N)U*34FWL$wcX9v=Uz%KaZ2cTNQc%&Xh6OuJ*Rbn00vVntdsaaCL71bV2Ar zRdCX!qS?J#Q1eC3{~Z-9Y`q;zOZJmt$CnsoT9Ua7FNA{}2SxcjQ(9o}x3$9+St@Z& z&_ONE1+qA+;}T|crH`2uReGsbMfb}VitnUV4xEcS0cw;F`XyaoWwXtYh0v1kqkd2o zJvv6x-Pt*+TMYn78G?g0{7zf2350Zb;n)U&rERMGW8uj?=b!2&{-!zmXPml3CN+1e zfC{f9#KEQ^b(D%1HzfwKLz2|K(d=o}L2b0+D&%(f@W?$up5dbC%MD&YWevWiElZ(PRgftOc`Y#SLt>@6wAn$?vu%59i_trv;oJiBPVupF$z<@USv(&<%AoTzmPId%?&a!_N>GiH zrVY%s=W7>6u9i1bnKkgz;^6g%O^|d8@UVTp`38cSoC_#kE&cnM>*Yz97B@Y=y+ya~ z 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�TId{qiRp} za!X3{ffP6VPGy)*18vA8MV}2dc+=N*VKU+#YT)B{FOIbx(p`zB!3CXDu`f!y(oJu4 zE-gvk{XLbV%8dP{c6Q9ry(=Z%l~I59?04z@;A_cpmOgoSKYjkuqx9i}T^$mx)hgRV z8N4-sAf-P&oTQh}rs=m|y-v?wA4=caFJFTVr?N<&X#n9wx~802{eu4Yb)AbHI@nbm z-qE$ceU^5|-$-xI(`Sz!%kZr&kCdOG@-^Dg7CD05);SnETA{=M;Fxm`^bjy0a(r^E zt&pp*M8LVYEXL}#EUfSE)wj!V?Y|@oA5R{IY{dJe0!5-;X3RGA+ zVO0c!Sd-W3fd&)ydoR;R4=3rnUp$nq9$UTn&C~PrbMZaXphllT3ESpJ=;knUGhBoW ztU_SmQUjm5Xge5e+n1kVs=I@iD(}}?rL>j4^F(WCcJHR=Cm*Gs|6-DUE&U!U@BUFn zEtUdt)G4c1w0Sf|oGl^4y(KynIpK-h)@q9b4Pf3)&(gv-1xr8la+f!o}9~wiNTYB0w)H5If9rgYtb_NVOV5lyyX{Ga`4tDhZ&$J+OlPLYm`2J z@;Lpo|KgMM#V1>K$n3sWGGIkyYbv&a3d*R7i|tVS4dfiXY>^-O=uA2n1T)()!2ZqG z{q%qRY&#wPC1+J==DD_v>FGe*-N~!;?MK7($6wx0KmA7^ru$l|f<4&Amn0?zT;__i zGJt^{RxQA!xLLiheePvQKGulDku3Xv^Q&q4-~Y`d{qk4?0y4K9KM*~0y{Ws~; zN9XB({uiI8?|pPvgByeN8^P`U`-kb@{rn^yKSSG`grNMPKNv{@45{uEfdQC&>3SoW zgdv}bE<-BCxvk*7^7g^rDE*mMs{Hf^_p}Apj*P+7(c%dg8T#)SXPN!Z*&XD$C*!Y; z-YD)|13_%h!&;6*a(=N{tgeK*ySbeyXDe zPL6dLuT~QM^)LGAFMswteRcReJs)GJexL)OHITy!2I>4ndx~+MgH)jmY3n5E;y|lj zzV%@*{pt5VPJjHvPc@jq9{ zpM5n-fBV%*`uk_E)D9fzyar#%u&eU0x+Tgy<=YxyVY{G_Xq6SjDhgquWc=6Q!u|9r zeX{#1{gXd?lK$YwAEZb3H89lQ(@g)Ae)+p``al0-oc{9X8q_&HRe9`cg^(mCZ?0{7 zW>3VCvp8&pj-VI>VnxM?%Hq-Jr4C{rra$@qqx5Hg_+ff-Ps38G@2`e?=@;LqUjE-N z(>Fiolno6FNi?=*<2gFDwu&0Sv&!J8>WV_;qo;}`Eh*+y`S_g%FrKEL{{DmX&wu*k z^dk*kOv!@=fPVX0gP;H3Zu*x$+eyz=ezu}R10=Tfk@8^&g;Q5?rW*WsNTlt7N8KR< z5R4PBii5V8_RzK=ll~nC{c5YT;l1?wM1v4jC|Oqb`1=h)v=4Ij-5UJh z;0_;Bkz?+-%ID$yx5BJq+v2L!+wCjg`6|cI&+X39&6Ym5humyQ7f5IAqSEcbm{)6* zm?tfSUBK%GBvsD{S;{ljE*$XhL^-!-uZw+a8c^+v_w8Ggp<{d-H9)`i0xr5f^+qpm z;7Y$hi_ej&;a5Y*9-{Uktcq&`2z26#N*oOgFopR$@T#3WV1wluMXLC7KK-Q9BImwGbTtP**c5wwA zT&2J_Lbn&o;I(790rKkD=2dK7xwt8#s5DFKj%rxrA@i^!MjG|d{0JcUAX}yvGy>O+ zLDr*9Cspq(LXx-ATMuII?5zv(=VU%!}tPd+i!8wK5_ z7(``&(ndk)_-rQ~YCmoaX>23He)a74zayie&449d)~s_Cm>I@Qt6_)|NwfGNr4K5c ziLDvEtYO5U1%Gfc(z$eDc=Wu<04qZpraTOAZ0^I1Zwy|{LdGUpIvzNssT1(`S$O(sw_8oW6MSD1H9f!}P6>57LwSGMtK?8No#kJDQ)*pi(u%9iK{ExNyliUfBP5G1^Yb-q%WoJYo*7RpX{Xn>L>ToKl$SiHS1fzx}d;iMoCG4 z_~fUL@l!ra*AMc!JQK|oALvTy{{2b%yRS#-YZ-{g8np7V#^|rDs`gIPryow!pD3@N z-P76?;Xl?$)L~Dvy$_GmKRm0CtGUDTbVJ5bcVr2-XShky@ZnOG<`iewH3O|+S z+4Gb155JwHr!RE|fZ|VtWp!HvLDb)U4KO^MYPH1ZMS8M#n7;dDn11k`?eu$JXyEI6 zAEZw|(tv_6cmf$5;^YB)ek6-Mf|3oL;$9vm4U&!14?a9jfAJ5e=|6luPCt8fr1U#> zW&lGz42Wp+D$_z=&+S--_<`!xcRoz%fBY{$PCxw4V-37%+a}TH3<;ig$g>(GKAfzC z#N3_$RW#}whb#Z~mH2B=;fr4;o9+Kszc@+1JRGD~v>(zh14=xk+Y;D^f?Tf*Fj9W# zmxIY^Hz`N*{K4)?`s42o(tq~{kJC?n^pV;c%EWno=L0>9suG8XN9p)0m4;S@aK?iv z#-3Yh^tJ{rDIa*q5ic6l3l16I>S?9IlV197{_sKiQk;0p3nwq0Vd3>Ys@Q z4_JBiEj5CN()Yi6IZc1_T(U_|QnnmabxC&GGPYpzwO&X^{-KWIOnD&8IO+%TaXt++ za53SCHmy zzR1~oqI;eesp$PkvpTc5uB@gA)woNLxdiHJ;&o6rS_9EPz7ip6?4rT5#!{6&jQq^R zqSM94mc0Y$XcTV^4_6{lN$FZfIw?TsF6a^m&(hV=%ILYC%REhUiQb+BROjl;bhtqL zT<;6Ot}F>DYvp}qoI1kGG_Z0xsLAum7MKx!hS3f@2%s|zdEDsp6d6SCZp2oE@!%8Ec>)Bc zJ7}SIF3guc8=k=gCTQFdtW2W_tT2W*!O_Rk{!EnJ1>*M~v%43J|qO)SAPN$g$8i<6eb?kX_-;^B(5S zB9np<0i0HUP>{CRK2>5ah%__DeI<2QAZdH)Q< z^ffELH$q-1>5^iz+2N{V3}k(z-K75dxv4T8X&`~EqVDOy-aCRl(Ej@e!^8B^ z_EGxrN2B!5{^UXWpZ?p=bwtK}k>pc_1D}WD%WwsU6*HUN4zWPNe|&B zzO+^F-M%MX|B+VL{QMw&^ZG0uzM!PE8bEpljsl))>nr`pFRI*(I_0N;qPDfo(zic6 zNdNeI2kD{Ix+6S$&ZX23_EY-N_wJ`JW$^!hUuqaZ<-x!LgHa3?z7dQY?2*54ctt04 zw50>M?`TC(!vFSTmr&xHrB>LK_h9A1@KPvyar zv|B#I)IM!G1O(-!`aBr_kGar$H6s?w;YnZSuT`f??&h^-~ z26I#2g9P+tz4?{$%7=RAu0{jasklYzEjvLA)WL^tWyD+nm?y3E2)8r}Ijsx$?K?cC zxm^H%9?B+O-fgt6qJ!JU=OS@YNAkQ!+(qbJFOD8YDfdd6Jk@b#e&URN{@hamiML<> zSxIN2X@dYfa|JR64qlzy>^UrGIz5a`H7*t=#^aq%NrQN3gJb_WZb7)bITZs0*se0s zJoc^+LF-AE4)rv8K(_5!_8-#&l3=f=-vEToipAiB;Smh$l3)TP%z>>3nvwiaD;Bh= z5Qi6x=t9xE(TW9K%xVS{b26(H*mA`kwB#2@J6;Z^95_iRUO9t10wmaI^-1j~Cx2A98KS{gV8~&ragY*Yqew_aF4}X%r z{jm-x-PgYP+Ezw8z1XS**n3I(fx+15;Dj33vIc`EGsLN)$ZsnokaCPU94Vv&S(<%C zf`T{^AaAdaQ~LU;jGvmD&Ad zY@YX4`dX_-{_Wo!r!T+UPdguKdmO2l?WmBQ3&&spv%=9iIlwT9#0(lpH1iW?Ha;`? z!@(7k@!x!v(trHVDpS#RcpV@teT*@TYv2BzZOzmNNit*PWTgWuP=?~fRzT1&`J*oj zur6g)j{2dkXcU2Y{#QpS{pEiWjUhDkJfb8SUbP@bv%_^Rg7|UhG3RbT7XviQ0{XH_ zGn3w3sG5hmYquKH8zdQAaQV@$ zhJysulXagl0B~5tz{&*9k~o&Izj!uEUojJXTO~n1+h&9dXGon@8BL$3NBh4^fAGbY z1{A)Ze)_|Q>5dBXSaPA$bJLZM0G5ILSR+nHx=y4Q>hOUGXiP@sqD!=>qo#L-fAD)- z>0j*KOXu4M>EHa-@6z!rMQZyUdw{f!PhaIiz1Wq}`mWBC*w^+yz73A*xAcN>oqQiE zmi(A@At662C&1_H21jX9HYzb2AbTP<4?mOM{(tt~`?<0tJJ3sbTR@?_?`k_e?Kno{ zaCbRNDn!OgD=8#J=$DoLLWO>lLK~sgZjiy{(hWJ&W4gz+@9HvL9w>Oc2YuhkoA2Fc z06Y{>t{Vy5chj6adGh4Ry!W1y=3ks;E2ZUh_hCIH)3?6sF5vVI9i6gPCsLXGKp_X;~e9O#srOLTgYGgm5zuZ4!{O( zapOU{|6qeNLNv`HRAg>$bGCy#;y<3JtzwLz1!HV(MS^8-C$twBzO~MGj{1Axx0Vq{ z6(5iT^cdgWx?LZGV)U*aSbN)(>v8%0RpR>4*iG|M_=p4h;(#*MPp%GIru_zba(!f9 zRXzfKVL9OY_du?nA|pPwdt^}IH`32kXL|_cYh!+Sv@l<$k=_i&%Z2-tI_s`VF%{^} zM?kmIA(RQ(V4%m-(hfw^JLlMf1Cx`x-Vy?@^GbLoO!e2Dm z*^AyhD&)`G!hz_beI91reZIi&eDCe-J@nMuzZbkOYG*XZfYdkj`HXztyKqmtJfCdr zpi0?|zX6%s{e0NH&O9S)vbj5Koh&G-#oQA5}(%;U+$A$sHfRZndJKPQ@pRL*98d zQA))YM`~^*R5-P=;iNSl90u|{+pHAMidhw@EX+u^*vsbDO%8Wu zzn@pn&!ltAoFDa_jVp%kie#%>!XCWr1P~8zet;2Xol|aA#7=}#P~N*Tz`fy-%Ag)H43F@uM!54QqCdGN%92L@*z!m%@XOTT(vny%l@!SXG5A@E| zcIAOQ7b_A*8?qlh+b<2Fa8;IVBX;*m|NLc2zq5W636~P9CBdUrYbyPhL;&u+RU=8O}u@y@>%rBhXFS z>eCy;>5E&m$vqB)=Adi$=ARy2=kV@kIyXCr>diVTLI?IX$X;A$>J29MIJvWm6 z;*Uns-3KGc3`Pm)Xhh!~iIpa-wMdf?JVhpz>XoU8wsl7QHgz9L|Ni}Px_pT<+LO|J%GFqd9jr8{Av2^3^bow0nKgPiF2xf^T|71R@Ft-J_ zT`54KV}*Q~Y_6t-X|^sqHAnx$900R5LdB{Y*-2XpJB6{~t=Cif{mqT^%g?b;4V|Q& zsTT&RX$?v^`AAh_#{U@o$MB||0a{0Z<5=@Zb7g=HIRxHe+G`xh1-2==G(VDFIW?7T z-{F99_J?X9llBAH5jdjp^@Gv`zw2h{zcVQ37dQ{)+_8!DDrdT!neni0VU$S~%$g`N zPvf~6LZpF#u}>q2v~doJGI^kQq-%cx`6$=sNF)N=&3?HWJs4wc3#;o)^s3 zpUqLH16DzN-S0^+7)u5sxA5(8>iRf&1|n)8AbLp=*n!~t1C{!l`t0gskEb%xC-*0~ zJJDRPSFZUXFsmO6X@r7#FIx6W??k^pK6K_i3UzW|CtjA8>x6z%p`F@)@A7=!?+w)r zVV1|p){W*H5q=iWvoZ!fe!Cw&>jyS0v_`cW4Su-PUzlo19o}Fz2>f^vKHfncC_{=7 zNnZZkzcmi)>cD7+EMn^mNc#mZ3Lx3xwlGxO?cujg!rnqJ1jzOVY$LU-fuFeh99Z@V zUL)#oyaU(kaEg0cSVA_QHE*9*WuHMggY^d*psNiG_H?~^L>K&yRbxErKpw%)%!S4n zGdh!YL@QXGoE-u8nUHBYT+9Pjse{sfV`c|H2|Nz+lJ~Igd$4Pe=TRXg4|9W8%J$02 zx`JW%dy%!>p>k@0-3K+1ako0yt{HFmy1E7>wF;}!g|8x8cmpg0w&G}m;6;w8=qNDW z!D3pDYSGY&$W`w9Tlmm7@i8JpTq-((E{F=_g3oX8Szl)ehYOOWolv}+sEi|B1tIqa zO3Y@Hl?tp@SmkP!`Ussrg8?(eMpeiROQSi>=jARW9ZV4F! zOeQGG47GE-FFE7a3;iTuzOaqNj9ykKPzGu?!kZ}W*4R>p!biE%zXV+A;Nh}MOrTZ?tN}-*t*nt2VKgpeW|5x= zam0%$>F(dJI?p-W8e}F(SMj@wLUI!&yM~qrtXjDHOO&u|-=kr{RSQmtw{6EzYMxGa z)0=13QuDjB>AM&fKpTol^wQ}XQ_=tPuU6A9zhZxPEiKZ{pJxipqQo3sWw!cyGkGqu zdp=+UEJalQQAy2KD`#1C@XF>2~_NU$3WYtIW7(7WNR94%^E7u79!ODo3rE z&h#~5(eyq-+aCv4M~d&omV!}=ztQ-WcR?!g$OBi1TEJ&~j#CX}%q^BIgBH&jl(ni< zmF5Ahtn!~t3u1y!<%4a5d?%M5uE6ow?+?F9>92m?OgHanM4{&7v#lmkYHuJs%8a#jO}r)#v~IBjOyZ>IBX2XyQ9W_tL_PeM z$$6Cs3nn;UV>6wa9ZKK1K%ZV&Pd|EPhAoxGQ1*ilaO`>X#qsprmxt1g2Y1NMQU-aU zoC4$Is=R*5w&Kq;?i1S>U0BI+7KQ$6S2Lu+}41#W~(+sEd{2wjp7g#KX8pxNOsK-U%o{NH+OL()pRO z^k2O?mHu{VDcxN6pytWwza>jFTI4gY{}?=23oy%hB&XP?>^qmwV*omqrZM6<4rd9p zvi73L0&gQDOo1re2saHLo?jz;tkeP|muaqCPQDv%QG?IF0H7B5+DXRNTyA+bSHnOy zK|kbxZ44(9qZ;^f8D`Fg=@Ks4+humA2+_`8;K3wdKj1dVbLX=^*7+B$Xa6~%^O;}1 z;SY%Fo0t6=%Sp8PA#?Tc+k3}O-Aj2@A#?QWm%SMViP=Z_gP`jJlS|0Ihevmxf{vmN z$bo+2;o-~2PJQSg<7ghr`_aEUOMGKIaz`AfbHKN$$9MFX5O7}vYp$=*$J=Ut$#Y=z zl`(&o`Lc-P#XffmKcak@%}><8Wil}xZ#Cw@nWvQJy~i21>&s7}J-@^b+A-aiwf>UT zQ*JFHaF$|f@%_Acwq(DQrx90(jL*ZTQQ%W8qpy5CRSLf0g%$C6jB0kbjblWN7S$Lt z5K6mxOKCywKEpwKDCm>8TOotijY0>;9AB4*`&f=`RR*UZ#?CE#vVSP5%c~tic@hqLRcsah{V?c-ry98RSZx0d~&gzipjMfH*g|&?- zlum4TLZoUZXN|XCr1H4{VOSu(<$%@3prU*WrLAh^4V06yRT2ub(D=E@dMMR=7eN~< zeh#)(oxHWlTzF`3{`j0~-c42yFavmseg4POBb1MeY;UxcE}xrCZ-3`v`r*50(nSut z^^D)FG6OYOeXkm2i;)J#2-|IxZDpD$&^=7nZG4vD12%w!9++AD%us*7nSTD$Pt(Jj zAE$FutIWoJFU_Foi!FH6jE{reie;gQS1tK!j0#LaR#D0?g0zW}IIF3xhv`ULi|N)m zx4~%eHG3VIc}8hFG%io$o31FKJuZE!9I z+s^>)mN{;FgkCz`N0EQ$;WAq_vAO_GLlsp)*-a_OnbAGVHbjr$(~6Y>U(>CvLK7@E zX;bioveWv*2Nlp7Ebf!x`u#<=HX3zfs=z7UtRVFeaOTp(^M(c;adYDD2!@go4u>A) z+H9!Gw*=pZqa=QF3*u=xtFMtky%?23+hp z0BAs$zhyhZTW9f)HI|UUuhW&YXVTkmpHJh668e13X-Mgl2k_(P*V7NLF0jqn=^%TC zv{T0>)2WlQ>7%bT9_F^))IbE!rs(^&(-Q45Nt$CH#m5+yHwiNY)9ng4<Bqh&Y+c>--5J>Z4>2Fu6@1XffS6Dj#KtyY47js2|me+Yx_z}+&l6MljUJk z!@@dSM{SOBhKubR+P)wF06+jqL_t(QJmguc{Qe=VeC_AzWpxzL5BSB2qdjU2eR1eM zqV${30mkitT?gzpsDBR3fFr~W#?N3S%N+aSGcTo62 z;rfF z;Qe6ksM?N!;N6a?J16W#cP4dEdIyD*1znA{Gl`_^j#nDpzG1&^uexV^1#h|UuarH@ zsKj9q?0e#>k(T8lW9`n@6ON}=b2kD|EV_yp;CCjlhYQCb1&p)9 zLy;C!>!kq~@l)_{X7(lEK5C^g~N&YU^$~IlYR~aSVm&6=nk; zOXoQtca}}(XVEZ@jWP3Fby$>3*>*Dm!x#qIeFWtlv{{DAW@qJpg%RLyf6MIbU#z9u z*RH2cW+z`heKP&v&6DYm-+PJK(`+0Q8cCGIrK;Ao;$yIsqBI7q@bd_T{|eL2n^`Jz1#TrW0C~t&ZDnI3;j$)`3#k!@F ztq*-f1s~C3FQf3@z?9%zaW$waCf!wkOg@3b^C;r|R~cY?8x^T~C7WZyfz@zj`^XFL zBoG(QvT@m{AR8!Mygmejut@IxF<*ru?(#gYHmKzcX;5HR{czA7riK7_yCYYgEK{d2 z3{m6;<=%%|V&j>Aog^{fqc1xshYGawqO$2EIqvH_9qdSXJ>F<7eC(Y69CoyEi-u~o0!K#IqSy3>9vU)^| z8@Md@5q&A2RYm|>EH@muhPNN4g_%dJ=9p(YGrzO8;LS+7wLHuUj_q`h&ieAH*`U*p zIs42d4ita;t?~2|R+X$_kW_wRnItkt({n&HbWIaCMt#R7;mJSaj8csBEp-WFNJ1c}3MqmU@m1V6@HX&7 zI9a6p9l0Y89C6@?1K%nR z90d#a;%z4$-1j7K9-OLOFdRO?`^M-8){)zj1LgZ=Pr%0lsCv#JHif7LLRSOYyZ`3O zIg<2XKLk0Fl>C93%h~HUk7av7nJ(5rYc~i!$4%jC%-btl$H(3d`3`}Hdyz3vdIzmL z<>0AeHFjX@&g|)}doOG+%uZDFk8kJiz#Uo&V)B1a)^@I*q}}SI=`$f45k)wqxuNm_m?u-!|Hc_-&NM zF`JcbGS+D~6=SNcV~Y$Ronfy|GWb%Ilz}BHUG4EIlU2%wkfNv#WwXjEm5nH*Rc5J_ zn?$kbKKLGyI?d{Y$>tgd)2^VfT*H7cjxzQ*Mun4{p)iqNzH~aB<#g9swnb6F=QcOu z_t*C+KW%dNu|9@4{2AYxiaQj6E5O`Dll$A7+v$T(9;9D=yp}%uaye~3x|Uu!JDUFR zyJyoMfA2y%!%XOLH`EugE)>La6}*+w-6WOkD({z2JU(J?e0NO(q;ffPD_EjIDht*D zR0$lk!tB>XZc~+!Y2Hjcung z)zuWAja{)7Io@n(;(uoU{FlfohxC^-&mfC`+#JuwHJ<8toRfE*#4F zW7{(gTk?&1*gVp)O5N=HY@U_YS$XuuSGPIOVI0|=uW-_}A$3J1Rpod>Q6kf=SN0c!gC+SI!sk9R`!6&0FjL&lA!6|`c@XkXWODRG0CdVSrd zLv>p8>p+Dvi@W$sJ2+l@MKVfCX9KeZe9RhF!MVY*G;7Pa(1|;2bIRQSGHBiKNZ|4T-DIyhRHldqRLrzNTMA4 zl;dTY=GX?gSeww3u@jaTm6QKjAzEU%lQ+(_0E1GIjS*0;;RA)RL=*3ttM=?zw&Y-5>;1E=AQ=Y)7@vDJ19ER8ZM-lb=WY3?81 z-b(-R6VBflr_Y?GeZcwK`^frzXVe1({E*8-@XA}McU48uZamKOP|OENl;aF~jvbBz zkFA%NIhAz{7URG=9;_@cvUSpl;F(4ec^4NjBWy>cAwfAHJGhkvOQ2OSc4&05Zey(G zm>S;TY=+b6(phnFI5DYi%XM?HnLhZ8LzUSR>C1bY>BhrGx`a{D@)t0oTw-;=mE+W9 zV=FDuf!2|H4Op9vjS%4xj4)J`c8y{&dMW3OX7HiC>WbAtn_KDj(q3sgVmwgZN1dpl(BG3&lJ?k+m?@q7N)SY$|KH|XRao{N!7TScB0jk_h(O-8C;_>20 zbohO#sp10E!M~QLz|?Wn{d=X?VUPUJ4+pyC;`yQM`J%+$)7gU#nZD`+ZxF1{Lz_Jh zGz`M4=LM!dK6dl2JN&%RycZoi^@;t|=dfVqZ{799tMZ}tOsm7|?^mC`c)BwZE~R(F z8&>I2itU$PN7pyKWR}L7zn<@T@vB@`u_Qlacb);;rmn=J(?TbN(g>xaS~Bb98%Z2= zYnT-at4zUmJDAvy9d+zlD3RC>1jT7^JT$H17MU*JRbCIFm{S3_QTW9)Ulqv{65RU=?ry0!|v%9rWezn zegA6uw?BFbrQlr1p>_68#dHJz7PODRlTl~qSI#oLtmhJjg>?=@w2%0Rhi`{C?5=G|hMM#E|1 z&?7GN+bB$fT%xwv=88)ty?CI1Xq02ASq(Bmfo`dz;$HP+#-0reZrv08me}l|$pWL$ zDk^S9sNgIv8x=gGBMb-ThZgy-u|r4=3o3or3Cn{`=u)w&-z!{{Lu_-IHb;(BSQ}Rk zz7}l*=wK!ZBhcrCe7H_w42Av#$*yj(+8We23O&7kb~?Sp3Cp$#I%mqp0KkFGfBXIr z+wIV@qhquoX9J)Z4`ShivOPop8R49e5%3%HK!Xc#>l`ZlRXUaa&wpyB5ASWI*DjBx zIcEI-!v`t->N?0>b%We0PhpTyP}pWjqe952;cyy6ECX=p8mGKD2?WS3v@|BTf?y39 zFrV%2UG(OW#Szb4zq9&X;Se??7%;@rmNASNrfpTSg^nFQP&XhaXpPHfj-@l~qvSSJ ziqjJ8zy4@5{rc9U^vd>3x^{0UeRh2{T|PS-{Nq>$4yztc-?_y4rqw3K0S+J!B4B)c z@zK5*Eph;0_%#%R6X|UCwGAIU_F1Ab#AFQW`w^wOAM0>y(_!3MGju8yO_g)+Y5a;idh8y5qnYBzE!>UohybIKF zY@Y-yn}5L$wlHIL%$8zKK8)YzngWxlRn|%4~#xfNNA%8qij3)M13zBuj(bd|o-0?FJJNj(x3XV!f0j z|Nb~of9TW)Se_?5H*AaIo;1HR{yIISpQld|Fk&&fJPYP{?YaYr#jSJnAH{=Eyzg94PX9C}bk??Y?v<7(0>d z1NlJp^2@Axc*f`70A05IsO==JCj?-AIQOJ{Ya(J8+)r(*_6;8QFuEsVKbW5k>;Xs` zNIom7`@+^_w^cd#*F#c7@ZFx95>Bn!snST7e z^XVMtHjJp?3wo)WbwO#j$xP*84mVc8s>0MC4*-@maz)jphJ|IfLJIl>g5u~G(#2aJ zz-4u6=D*Py&aI|`N8r~PESD!2sE0)r8hlVMYMs@ehTcCj09pwXA-(mpT;yBO2)s&R zc0Qp#v=D7$nt0b~UyU%0P|5;edxK|`Hl1K5c7`-AuMV+V&bXiFeg=MB-oDQERU6bj zAAsyW^f`|eaW8oe@TRaxADU-d7}Wl(BpDx@LJ_RN1~n^6XB8_^5Aw)IYW@H8sy0nj-4vI4x{4sj+0%H0Xunlw4RN3C4}Al>g#JN?+d4pfk%jG@ZV>x0*h^bwB+m&5~E?7)r;n zHPqECk8EXMf#h#L31^$1LeB^0tLBZE0mZ{p_P#Y4Y9U z>GC`VM%wNehKnnwhSSSux6*Gud%)_Jt0w)n4*mQ|ENlz-_qVkY>h*qOuRFpfz*TJI+2yeZy5zIpA*< zAGOin0}!C{OBtV_Bqh*%TNsXA8KcQoMW)8eypw;>I8bCr{`Pb2_};eJPn$$J{fp>VY&`ds`y+S6fhWO%!!axF}(2k9;f<;Et5}{i38RU zuy`7jm+3*}6XJK|M?6c~<@bZ@hy%V2y)#!-Wefzk1B#9^cYrC;SxMQZYNxyrA3O`{ zB75Yj__$NPQcl17VBF}J^Pq7`f(j^W(0)g7W@*fZZJ{^a`Z_L2Eq~P4*|#dCRau&{ zS(IG`RcMd0f=jiXPUW@=OaD}Qj)BK*KHOf%1Bl~ljQp?YEWE7vFRV(vtjw3U zd49G0HRN5YJRHAQKo5(aYlaT zA$+Fdw!yf@B9y z4?^&2n3*6fKiwKhWxW`zy{jxl#hJ(H;-@; zBMp!)Z;hr~EJykL0dcHkaMP|PD;QRX)|q|3!2#o2X?%7fjZLt6fNB1*8iu?YPFB#u z%acv$adnJDur`Jj9iC$X->6TVbwG-T45Ml%-EDO23-Fe>2CGJHvu)7V?44ylUV|`| zMO^@fh&trd(Di|P2xMtb9=v+3k9 zR&rp)a7BqnGhD;ia`UDuKqjcmRJ!%xe){q@TQ5-;b}Wj%a2z|rJC`|t{TMPChqc3@ zu>O$`xw1{=BaEP(*cu8N<)Ka`o4$O}#+xJQ<4-m?z`c>adubcPCg;6i+_`*um~EPx z>5DHOu>#Ar2aI&~AL=?zoo2RI)56$NdW$-o$G9+x3~D;K2_JrU`w<6E-%D>^T8MEH z(+VkWf;L54YXp@h=&++yblE2Pk~01EG^x%`QLlW(!`;X_e2L2K;A#a1))&26FX`Z*u`70$ku~l+NF@^CTX7{^4!q<%_ihY z%AL5|GTzI_Cs}rHX-6^NE)E>ruz-B_dq|bI$5B8G+648%GCwQ_&3OoLJe+syw)GMU zFY0mMHh*wyP=>*rBc44U5)w^@tnd7g>i2-!si7S#I_uRjKJc>JmD~wDK&UP^9k>U` z`)vf-jYr*Sb>2QJUUkzX9Xrw0mfil;_QTZ)S&i`_{}u$s;;#XAScWnqkY3v%~3!SC6M3y}@D3=TEWsICJyGg~CgPdRyg3C=3cwa-_r-9Qbt7VEO3A zLS$Dc^Da8GFVRf?{$4Zvhkx2mKmE;Cx^oY8CAcr3wmgMG^y1WF`t$F-n*R9R^XcVt zQ$crDWs8w|f^Z1!+Ay=qRf?)~wX~o@ts8|}uJNi0A1-dBl_izEW1+y!Gk^2Uc#?t5 z%JLwx@Lmj|NETDYV~V%_Dqy2cB!@a#q-QR;f+3>#EoKCwaN;N_5s`y7L6xc!e3Qmo zMKNw{DAmDjtK||i6)NAy;JJn(PH_Mf_{fr&MgQH=619Uu~q1?zr90L^f=Y$L)foRm!|9vRpYnrx9Iq zrpUXt3z>|18Sj-PN(d!*k}S^BcI&8ENkY6I2yR*B`46*XdGpLzns{q2Z7fcs)}Ds; z21XeU1ZIYOoJYXcK4RS-!!QDoch|<#XAfu7$R{J|*1bkrU1yK~k;$~&Tt|7lo~A}H zNQ_f@D7rEqH7+O@Ygmr%tzgjNOoB4@%71nN%Ug*dBW}NA9~xn{yZiZ1VvzB0X#3k1 zh9XtN8V%ODJPRX^hd`k|WPPZe3igm;+128ze%0)4e#@E3>7}#BgMY3@&`5Qc>H433je%hU69|R{S3FE03!J$O ztw=T3n*E&F{^Q#Z(gNEIz4OLPX?~i`st}k>l=hF{=~uTgmfWVzD0dTu{=Mb<>BjvH zj8aX`SAZtib&Q64=>%sjoSaO@XPfB;d<;jPFiB9~39jHXHI3Cl_HE)E#{etf7`=wl z?RyjHi<`sg7KWa4Q`y{h@f7lKg~PRfilJ#0)5JFFc~;zLG#v-gOmihIz?1i0e>t65 z7-QuRvI6ce7=u2(yOvh&Jc^YXVN8M&d#=A5lRm%{7*FB9IU~0T;oAXcpjLM12?1(vEwGXJ z*gsv=KQ^9kR>s5O?bF~%h{GWl z5spjnDxJHz-CbmbOMf`ZbYFj+I2+6uKE?*>&Wc@U_V!5>s%hh4IyJG9-nlrP{+GXa zJ-u;uJe`{!O7mz3U5TJmxi%bo{AbMBXrbS7tIt4`f&tB#E%i=L4h0>GOK1MF>rwjE zos|COgGTzNpD&}}M0vNqnJx@HLXmkBqr!a-(49&D^Pl}idh6;`Iy39ca5AY)Mlqf|JTQJadM4s5S6!qbW7ol?2YNW^#6VxU!a(Ib&gnEm$Vh;D;hFD>S9gJ^j~$ z_m&Zuxo5ag>IaXBBA<#zmBU-?y)J%dt7~WwUJ`BN2D7tQQ2fTWneq>QXasTVsZc>f zpM6ta`SLqXk)tS@RT8HrF{C+j+4B>`?;+6q8yyEetMFuf2@vD(*fw$ocrlcK1LQMU z0$zKWtzxcRYV~gs6oqZUXS>KN_kdrbo}WKN8T+f@^t=Cu&0>%d>+dr$zkf)9my~Ck z3b%|Fr*aWn`2<9Ui#+myhOwRGwXosBlV^w9uAKRcu^?}KWUJcr(KNzwxj^IU`T2DI z+_6}hFz)Ps%H9A-zF4A{RO&XV!Xx-`jXiwEexB0b{qt)2>`Ts-zzEP(qfdJcq3|EW z2;mk~O0YEQ*6B>k@L-vA4g|4y99w_^qmgC`Bf@d!?~kLRd(Qw6C$m!+A zC+XhiEesxm|K@u?O7FcilV*>N$5|n(@P7%IPj0gH z*e{3DgAHcGQ(t*JN=F^y@B~*XSWCOe%vd9x7+y(C=1+g{9nLkFLEfOzqaE&{l)uJo z|66xG*m#aUHi4nzRJu$2Pu^cmr{A4Sr;mG#I0h*iMjueFRhBQ? zQQ3CMU6R%u8|hQIF4W5L%!=cb&!B&FA$`u77oXkON^f15g!Un}YU2>+^TX-%$?5d< zT}oujs*u~pD8{gCsJt?>oL;+vk?kZWMo^Yx!ga=;dk^3n@)3%A%JabFylzADTpqjZ z0r4&##6S?repp@;O8kf5l&x$kLJHtZ9B@vg+ow6!`)rmSdCNBC5h8*={tzZ$TS{7S z;><85=5X$T_i#{kV2SDw^Q7>16n3bAr$e!&ls?f-ewK~y*#2Q*yYa-Z{dB6)pQz^~ zHh8=XEy3p<)9r_yO!Qfj&D=SY`Rjeu*m&=z&)a{aUCR4LbY7wNqDm~4YdtJWMD1R~ z>x#h6_t6^MfxZ3spv+Nw$Juz+)e4a*mCH%K2{;Cp*!sN&T#xSSmv{St?~7#M$j~pv zSe&ysBFpE)b%iPAUdO)QvWi^w!&S$-55Ldx*5HCn^WNcOKD%!EXdAbF44zf8Z*v7% z!unQ|+b_gALir61?^ATW^4wdvbavOT7wRbH>2bif=x{DM%`Z+RX6L&f*m;1;A1QX? zy6dlusDv~|ubMwPg5frfJSb$0zH)}+))sJSZNr;h57+%U^U?3K;95n?j>J^m)oFytYFytZeN7#AY(UL_Quo+ zk!g<4X1s01K4nzQH!b(o_MjfyfB1lN9-#FNr2du%RgTHwZhFO2(j#L$=OQNBrGgLq zIlcE+=uW%`9S{1CXT2pq4>2}P_jtTJU5w$K?AjgtIZpM3Sm*CfRoW?D8R=|ipKZO+ z#&k-keXkSFurZynM2N8q)>z}4K7V^K=hQ)dcfuP~^bk_3Ss!}(;~IntO6!Afu$)f{ z%E~()IkxDh^?Y!6sc;RR7Y2EkH-2bVY2|s)%4Zhf0<`2;BD{>wvu;Ca8nM2?_{U2* z;PS^E_nkF7y1^`G6q0XU98drKzdn(^$6>3dW=7ab1)X0ZNgUw^zt!MYwr|QHS?bXj zMk&}A;*i&(#CyOne3LC@{tJiJ{=-Mx>4UGf)4jXQD&|nvV;sOcH+&}@8@a{7(5KU% ze(!4f{%gn5{1p4VTQ%EK<(hmdmtM=_$2w$}<;mY>Y^%aK6eOtpf4I7pmYJqJgy!Em zWo|g%pDks;;RZ5({-dT3f3Bhj9W(10EvtVUlpQ)t%5`RUY`sJNIM;&qkv~3(H2?U) z?xC;w-2g^f1p?&^3~4vcySnWO_%n&3aEdFh3CZfsTyM8yq8$F@a^8N)?Rq>Y*v(T= z!=s3H=D3#{Y(Hjwsdg*-i7^%7lx2~WCAUggzlN1iqT<&rpy-`nkgsfCBTnKnyuNZ7 z)9K_H=;MkECYOE~6($_Ap4%w{no|%w zRvoLdW+A``MvVg?7dI4+37^??n**yFEAo)v70{z zDS7BQ7V=v;i^IU7bEJuZfNhYjy4rx%FCIMZOn+B!e0nXV`;Rb8K%38umEjqDqv^(@ zwe;KH-A;e@DhDKESP-`>A+BB-Nf%iOlRkDfz6KZQ#i^rL$={?M>7_T@Z~Svv@THrt;z8PQk)F&a5Me@S$>p`76MVo=p5GVuO?cgh|z(uFQY z;C}SV**Mgh+4 zWB;zhnqRpomznJGRpyZVPI%KICO`^Yo!12(I^uoOJ5;nY)q(r^OI!C%Y(-jjxk@C3 zRK>UXROo5fDtuL6Zl@L;&*Gk`$a=|KetCA)sg9IB1j~g!(T7lx+}2sjETNQAFR>bT zNNo>~!c}eKt@4-L#_Z$8~ntXq3jdQCM_nSRWrzyI(&(j{WA`t4!ZNpQ0IXJiB*gqcH^a~59?M?&?J)9ckJIqOh)%&0?^)HeP+ zV3hcNck=f`(E*xEcTDMdvF(6+egggZwHtlr*pm^vA$We$N;P(2Lcg{w;N5B6;k_$b z2-pp>7pBNo`6RMm#N#p0^pU+@6gw0sMu7J*FceyLK=^`2%dk4rY;ni4((;|v%445t z`nWsJd8yEfEa38mJkW;e6=krlDkomn^*cM5bjLsbRUEqAj{EAz%wf0QA%2pBS&yHb zXWJw4fJh~+pR!UI7tt20JnF=polv&0v+Hx3YS>Wf6THRND<9oyreA!ynf}YKhSINZ zq;z|E8wK4u^S0NR75yNc8h@Dn;ML>l$KSt{e)#5D4v1DsPFbapDA?Ln{ED#jl)pkl zn36*gDGeJa0qJXRJhQy4pI$O*rc)n z!iFYJ<+EOk(y#Nw!X1Mi5qa0M5}572x=tmTH6Len0O-ntm?ckt$o-7W$cb+ho-N^a zIvyG=wpg2jEHpVY!F~K2tY%OlJ_X=A-SQD)}0AJh5I!`I(=Z2|IFMgY>NCO@{VSLRd3WNq9QYK5Tu z`pne?zu8Q``iSk2Xy^?T|u8xXzM= z&p&4iAx=c+m~RY&Yt%9GXk&FLUB7mX^GxPhG%%U8zi0xxcycIRIWv?_VJuu*XSE2l z#}UO;b1QfVfp&KLkvGaJqDk>)tT`Zz1F-u=!@ z`pLDW^wk5`;AoT?#rU+G&dv{~_g*`fCb2>&;|~z9>x+y@*Vwk{K{LH_0&`#`uhDs1 zqbRLqAJ?dW@kysq#MNKov5#f=NNW8As;UmH$f%LE+RY*Hp!Cpb+db+}`Az~F=CXH0 z4~ge!;Ywzkq_ks786~FNOZ?&9#lBa~_A0SOVbm=nY{l)6S$Wq*`MVL&g2Pk!bIzyn z`*aKJ$A%|DLA@aRZ4p4dToLm`3Vs@GHh3Ag);H2~bs}blO&8u1lsg|CX5i{Li5lc+0a>e7(*E zxL;gJ%lCT8#`IzrB0tY_jsmea?hJppzoqm}tj6Y)@}8;m^oQ-H+i-u#b{Tu67syUH z+p#>$g{X5;`noB}bkuQlLYU&!87t=J)D=SOReDZIeUZqj%hXPEw+sty;L;j9WkI;A zL8{z$hTLCD9bX9X4&~Ix$a~w2CWi3IM3=i zi|p1%k`h-i*WpWCxdYqrAyMMXoxFT}7WFaZJW)5p;44-R)IPkT&8c(wULhASUisIL?_QT-R0b56V@jyp z3qUttjjy%Ct~~G8W(B`@aQTv2xx?jc!TRj)w$Gu8&9kpkrl8$)EsDyRvRhl4)|Od$ zW4I<$Eo}WggTnU0)yt^&o37VEX{m$uC!>lLa4_IrdUi$p!X;|x+;upp*Qhm&=*i=t zTXi_jh3ugG3xyZSj&mLsYpS8_a+}Hdyid5>%ixfu71UzRZXQ)3IEqpeMd@dYW9c7% z-$--pkA89%Lj!MRu38A?DoA`ub3b-3aTX=!Hi|(%c-@X?4H{H@dk8W5=~$)kt1mgc z^%u?b*FRZK4^g(RGL7E3_VaA#a&q)xIybqLUOv@G|LdRsVS4S-F}7(EJC`zFszM2Q ze9sZq%ff&ZtK5>8x*!1U8U3Zd1rt7*#xY*sYXQaL1|!w8)B%v-w>SqEgv1 zl#z7=5!wy3lvxAc<>0Qr&)B6gAu+OUCAx54=bTW-%!k)~r+L~i@mWQfQmXQ?DxF1ZcSI;&GKLxjw@vsToPI?DqO z4!3`6Opw=2=&s{6)9Wu^j8!600jXi7#MfW3^kMmKIt9!;t2{I`X|S8(T!yuWU!+G%ucpoQ zxirOCLd&HS3??tnr}WmsHfJQPq(=?T#ekQa2>Lo@`4;4gfbu?9`o0okT{7dFFIu@+TNirAfg zb^;Vv#+P%}=`T-=I!@E;F!eN3;A|A-vo@30=3PCX{+(zdsZa7Q&=&RBF61L}mlC{Z zzhtyTq-x)GeasXP)xCuCB!+^+*tYXYQ{JE3u{-&Sk-n6Y;SA|~&Q;VGqT(*?Rh#v^h))|MaWy!Th}pHPM4rx{L0+iD zJ0fkh;ySTBUdTsKKD5sK^taYS$AWA}E)tkM~Xx~OAGLv1j`2V>?X&9fc5kcO`-IYqWBOvRtNVC8hVC%jw3~1xH(pViz=B8J=8l5LsxK-j7%9 z)VeQ=^Yc){I#mbr*IPAz9PEv+&R?Ga!y(g|FY)US{o9wbon5GCZ7c`(MO}8Q&cTa#!7${0Yo1?&>tTp@)rRFeOjC^osJuUt9SLw6QPNwf%olLL4G?HGu zgkqKLMws-Q&=FR0Bu1&RY|4ob0~ExeiP8AG?*l$F@iR*v6rNWY%IU#=KIh(iZ<3dXJ=gHf>(i|#Bet~uMlv)vRkM`&WQG> z8$K)iY=yk70c{b_E8ik>+qt!m^ywTERH?hlBkrK%n1yhb971`BH_OY1F9mTMo4NPaihZfBx`Jy3fJYi9>Bub3Bc5d9bj1_K#BVCZ%g!*hGW5 zjH34uR)&=ol+0U`X%74&Xz%6jC|}C2CqoHLTM!I=alPzvGFeEH4deHH38YkN*28Xdx=G=pGY-^Jl z_-kx0L|viH8TdNuGlM*z9B zbq^!a&wqU{{o!}#(lCYtjVi~u-hO3{gWx%M{IgrE#u#Ch#8kR+X`ZdUrqToCTLagf zrAGSs2NGpX!IjM1q_86snR~0$>YgLQC;0uN`rvEB?v-ALWL#^}-;O_+&#_Wovh?H4}DQ)tKeS1U1EZwf}TLu*W|Hxmuv@2Dzuh+9bM*m z|0)+A)!~gR{53vmR0pX172ZB6{i6zP^)m67L8v2>FyOZ#c*f9OyW)tAP{MtGF*8FFB6!ve&$2EUzTe}D=U9OZ77(gniL09(>>G40e=d}|PH z!i+rKD$-Sz?x9{YRZ0)=icpGF#pY`fZ+=~IHctz~Y-=yVB!+q#W)7jmpGJ8)iINk2Cu%{Ir+}ykRRn(qhRQg`kh&b(Zt`$DQRSS$@_idpRu%6KDNCMLY)$0%pav&%ld zdfPceP<&FX8Kl*!*-x6f?D`72{CUboJ0UP+C`Gr01e~aI$)|!;MSuC^_KwarEmHS8 z9GZJ`5p#fexGX0d0Jb^TA&*Kv5^0|FkOO=hE|;J%mBs6}hcpwXAz+SpXU>PgC+M*z zoi1`)S^jlG7?h7yX~750qwOa)DG|lBK{Y;-qb8;c$x@>ULXvKlU4xhw(PJ>3fEeN@fVPO?q&Z3uI1<~l$LBf8D~cSF%EP-GuuetfBi!G z&gHWqSJGpDg=|)2AfGhKx>Pd;O!6K>Z>AQRcUi_g&wxb&Tz~hQEofn-1%zxt?<9v% zPYrLRlatN#C-0qND<=7cvksO)L7Nzh9x+3IjlRB#!aY{WU`z=Eqg)4%GBMWNz~Ho$ z7KR_Ds~6_d+0z&mnCUMnkqUE~^9=s{N0-vphQ=lO8Zwp71+k49tXjCh3Z#WJGd)Qd z+?-+Hc`j!-dHSt4htkh~wUzE;@o~&qqish#2qa{)>gW0^r%!p!GS0XI?>Dx`)8g7h z`sC~N^aTqiAOLJYlfN_0lF+#D`o)oS?Td|c=kq&(|CNgu)1}L&qn@r#V)0J;>d{vE z{ihGp*76i9MKp+wFrIPPHg}CX)$3s)fxA@DNYG`wyNX6bT-o-m23YgTu`KNx&a9I1 zo*nlPuo`%HbH54$8n82A)bk*4_>P(`!+WIEFZpa?MAtlNpjF}`0~H*7uJ z2UR31Ci}%z$J@{E<0#78snC9zUa+`+RkQo`Z`=Z1?)@tZ+ZYoV-}-<#a^!Z>XDR+V zl_Q5NJK_vx?(6Ar-R;~t4 zuUMs`HwebVf1%yzRzQEt2^6WVn0me(38z4mq3W>u3m1JB*@8LF_}rRO5D&M2&1C@- zWdaeCfpVUJF9iEQd~TfdWf`(w`D)*u8#2E|g!sMC-&Fz>Vp)VK8qG$mTx}pKQT^`a z<)hqf`0VGd4AU#(3jC4ULKFKM6N<6We5T|;4W>n4#I?(5fmEJOxbD;^6@E6QK3;8V zt^0Tg`sRWIen@tFIoR-JFfdbZ$-OZZCp#EgA0WF6?*Oc%tGleuQp{1V3`r>DjdDWX z3(oGlc&fMtF5-uu|UyKaOdq2Co z9d)mW&pp!PlM7uyrl2JH5m|`+IJm!pGJY8ZjV;n8;LH zup%|zZRr^Aqh0Y=$-^k!XGb(Hj4*>)WvU-vDA|EiAzR=|y$#0*iAQB>yZ}p*eiVP~ zg@m$kEVg#J#vbNhFKwo=YwIWkH`4J_%v`?MOcySo#5>(YNxGdDCWg{6_BNkp@0IPb zkvP_Micye_K-)N)ZuumShOtm>auBp1f-#dGrmU-^h6f&8E1b2FWH0OP zrGZKO8i{gy<#d(VwptElLsygqox-X*x13lBfHFb32MhINl?9l_Da4uhGiYLFsMiz% zHNldUE$H6H2x4iR-3wjFgR>gRKSck??@N}eVwNp(z~L$dvV>w^1Cqvq`yB9npK~~t zsKkWE1>mjMF{Y=#bcQXCSe3Ad0^Su1keT@+1i0K7>hrG~>Dn!7#P&iepIs3&jREc4 z$XDr|OWW!4iDT)z7#5s?ZZ;sml~=+%8p&IejbRPqNM+iwsOlZ%-8hUh5rEgQ@DIP= zPCx&Ml}BqRxS>a*!VD(8bF=H|?5X2v>il?Gn3Vq9Lr$I;PM7JwGoP=g)y+|q;uDmM zF@?RrwyEm~^%;jJ(-=)W_he>hG0lzMOK-gLayrdcNYWWCfiTLelhdQ=U%q`I;RztdkX z`-6SJcvs9!1HajvWc3rqo28NT(e>^0+Nman1?m7C=QpQUPi>~pCLg6WRz|&Y{*`q8 z+%aS}uLf{Ctl!<-PPgw{s}ahEGA>t2*iUWOLJI&mLe)E?e&0Daxs(BA*FUoKamMBp zjS!fbDj4n-(h7HHwSVRQX%872VGM==uK$UH%q9}%?2514OZ>CH zOL z6Q-iG7vh^0BRAT8uV3Bv=EY8|GCsF+)tloR)mWdN+!>Ert)ZtS6s=eyd!1m)LMPou z_QD=4CTfri+esioa4p!;y*R`5tBMc8^_y1V4n7#h#jV8WsLtOCE+U01j(CHu}?N{K6 z(6wWqv@btYo^&Y(#~i~URrgAH9(auvMOPgHe1{)jZ%S!9 zfIPuyXTe!_XU$0kb%Ht3Cf2K}+pYxgF*+P=Z`=40>z!JGN6h_Pywb_1R-(TAXlq4< zC&zcj)kAxw*I{?_TYKm&O}iWDe$)8)e-dc-OU5@1-WDrcAe9%UeswG_c>Q3yTPBBX zEtO{~Izo~O+b>Xr(Uv}Y%Dt3FbQb3D7^_?sr(XcaKJT5b(AOEe|Ip*&i8}X^)gs_= z@wN~=MNfXQueiVKJ4_ijl!`q5(Wp=gH*8~6nbT&W@xD zY?E>cMc%{$Gu_eCee`uo|M&^378ZfqqJWWkFtMtF6%r#TG8fVEPc`o|qxyDw_v}#m z-~Wd{LJ>HbW+^_#wdz4Dk5tU6@Km+yhe&?%sXPnG=U2jY?RPS{6OxR_=cnw|uyBhr z7FIa0SN1sr%>^6TUu6)D8RI8BfmKCbdA6Gx43vrCfNMQND@7miQss>P3SnfChcjF% z{bjopXZEXf#UMafqkx7LpHpVCzQU_aS9vRcq{Ug@P57ds)$~~!QlmmKF1TtTG3$Jk zS>R(B1vD&7Hb$9+u9;#YR+Qwr%GK@I{$?Cw%LIGO52K`?V@s?C`!QLT6UjXhubf$5O3d}<9y8&&FVg^1#OA%>Sp@*T*y4u5HP-0Lxk<)PAXnpH zl%`e63@-W34X1|L=Fmg5M^K_~H=1dJ8T9gVp2AnC?=rbm^oHzb)D*^Zp3AmwVt|O+ z!o&@f&Czb0g`n+WRf__pC}GQJ?r(5*!)+Aar>1R>T(`NYgkhnXKKf!M`pqWiGmH+8 zr%m!oug1CYiK&=Lze0O>STYqSehV7M(i}2>?D!1l4X{s=@X+iSO?5bc8bb!W-EbB> z>Sr;|P{&dH(I;4$qy@ccu|Kkx$R47|}3eHU&xLJ-l}NpaoV-oMUUH z4cI33;VPCH~+z^RSlVeq_ku3 zZve*C^F9t0(I1o}`}r>JMS2h0r5bxCh3xLii-6b@Pd@;gGe41XpU7>^SPQvFzx1+cD)G{hO<*LYId7aygn{>v7-?OXY*~&Lg z+$Bya@U27I&F9-s{sxGkJtX8`*r(;Bqww1omLvBfaG+&ah;2!t{YoPqlzaydgtqn| zD1OB^#6`iciiu9p3L#3SMcy`!OkT;+rmx^NrtWV;6kn8D0=@TM)V(l|5tBbu9wXzk z$g&5E9*ZOgHy7S=cUV{Qzxi;U5~s%l)U=uuHj*ke{Vi!u}}6@)EKOcHXA8;{&n0ykuDG<&L(Qhr8sDq|CMW&0b>l(|&$CH5{KO^aKsZ&(M`%z@b1;hg_~@^T90 zDZ0*daiW?2=sTCvfA`1d(&bYqL^Udu7ZViXDrDVTd>ExL(i$9Kq_6Id_7$zfNKvkD z-jqc6W{mU3=chSa=BL{a*b;~p3(gMD`;t5B+?4>}YJH@e2SaKjps5@Q^a<6B zkRGcE zvW-k%1y1&ASQy1GZR@nbx(a{dM^G$pktgctsspsTYbat@#X+7>k@74FUa<$z7 zC*aK%?FBLl6_5MxZ?!ZLLuykJU8aA4Vhm_C$>3Ycw{xbarR6)HH9Ky3kx(z`%Si~`sg|< z2Zm-LZvsxZa=^6-@>kAz`F@gR-FOxH8llFqN{mwv`~3}#d_KlNR4u16Zbo%tx zcDi$CE3Hw!#we=IPyAQ-a{IE_CKT=11=9u=x~0*LbRUCi1B0qYqmQoNV5P`HdidJ8 zG)V!@?mvHeF8$zpFQ?_rwRDQDoJ1|%%E=dZx6|)Aq{G!aZjEKXMn7zk)d-jO`rZqyE{oJb=p0^)$ua zPZQKPR(=F-`;Zc5+ak*p=~$4Rb&TiLdv?0dT}aw&W@IcD@!y;6iWa;2Rgeek-;?yL_#jReU>N7vO<{ zj#Jt7(%z1*r#ZIyZo^;?{V~o33ZH+~yF?guUH?_E=Q8TQokW|GQ;qMK^bvrh$!GAr zIIbFTeb4G~rj@$vUaxqos6g{gK9#)Ph>dUau{+gp`#IE3&2c54cW91>J+~_81KyRq zWe1A14mJU+>t8py@Ru7dZF6!Wq za13@G?IuJ(am)S~C?d9s&u{V>Z|k>~uZpXn36=U;7QsC7RQ`gX8c1Wn&u|&6@&{O9 zekrS6LQV!p&%+a#Qh`#`F82;r>;l)~(XKG<#Jv@WrB2i_o+5_rm*ViJyY9w!y8s4U zK9Clu^vGMUUYEfN?sj^c4;4-j3e}1aKF23^J5vXZwdj3%V{GXqX`fhn!8_%=H~l~{ zefz|LFupLE)ydIIx%O_fGLyX}&xLPAuu9n4N1RFpXFTUJq6l7EEK6LiGR+5kz*e%_ zW?(xl(3&2CpXuGZZ?-WV)Yqa{M0>OApv67o{5G15_R7C8w6$VjfK3yMmn=Wg78#s0 zUayDN(hP=$8I*(0AXe$6tT~>^OZU?cHb$Q6V*|f&@mYqlOy$@ZaGUr?QHn}~km#zJ zm9BCbNDl55=O}Q_EO#3m6{%Yo7M98Hb~|pTqT$w$wlS5a+*KB;`SU}-BV=cqYw7s* zMtbX|`SgR==eRGVV=66$lPDCmm?5I9ZMSBs>f)H^C#9ow46Oki8G=x2sY=tBsJnf` zmvywV{8aHupXYLuqne);F_c{!2_RFtSSq^ z8e5z3$O`ewE{TY<9*PLHmn z%kv}YyO$Qyx%sL1(BT*h%3_59D}jD{3&Q|2@JE=DJ_aol)I-BTtgy6gsEfZk>tx+D zC`~~7B-7U2!f1_lTW6)o68!k`{;l*OXAE3A-$<{#G{U&RiUi^>92-r4_8#X%Tpvoe zDR+52Zw;huYgq8GY@a3b%#vTgBJdu23Z27h>7mjZ9@pvDA1x21k8f~T+DJ}(9uU*%oJD>H7a-6p6WC9U;Ff{)%5z+saR!o?j(CI zy>%XA$$DCV=ei9#+4Xyj1*~3rz&1=8AY7fGjK&sS#b^br<$C1-M+l5K{7zE2N!n*r z0~_=XNiz->)m#oqx1&<$o1pMHPc1`6(2B+rU`X~b0)Fhaxs$>i} zw6Y{dmMXr=UT!(9glc!o3S3pb5Tb00Z4G9T>Kn z;aV9E>?!mPf=HSMrNjCPFW&A3G;WV_BVU23@Kk-iYDAIXGLPc<*O`!${S90tm~pnB zLB60sUnr{zOv%)dv7Wf6zqDg+%RRxL8-Sgp^tmDM2@+DIty5ldf!#`b6^b!?65tLc zGT?q>q_9#X3pwlWeZ<4So%o4LyCJ<7_(Jhk6$UOcse zpHV@17`9#VI2L`Hpp#>58|B4jv4PtCCyz@;520io9$IC^0<)sg$)V!QTW924xovrG zQIQ-n6$R$}{<;Ri6qT2;p$Cr^w=?m8-cSw$w1whwTWz>nQs_{>Y*2&6I*}?;{RGiz z9B_Yr4Ix?StfJMDxS(SM1H%~UBbzJfr3;tSRi;AEvDHkjBT&-Ef9Qs*NOKDjGu1?6 zC?W$OM_4AjP=#H67J>#K*y;{4Dib|a_bz+Wtf>qHfwR@O!r&3;Ed(dxK+e6iNp6qO z9p;CDfGsB#VJwIkXD2g*nMn6?4`N%8(ydZ3m*KB`D_4%qS+XkL4`|vZS2kM8*2?+h zD}_`nkjIv%;@lMqS@CWS}da}}XmAvkWA2a1uyn7Bq2E$0? zYWn5bV7-D%)Qe+?QT888pRkPJ(;KVl;nF-S3}^=`FbR)Na$5Pn`r*a& zaD9<2d>(NI3H1ZPCj4@2b|pnZk!?Tm;N|1n_tS~t2k9yb^zUX?7vfuQ@3BFWGA87e8Ia4w&n$Ji3+9D%Pqc1j6y=B)php53(y0_tTGGT}to0 zGM8r2;wwAHsKCW}wk%>f$f=pFw79g2VMMv0hsZ*HlwO^`|37>0`DNL0B=}`{*VWzC z7T%Kp4M<`*L(Xc9R+^#a!>;z|gLIC*NPn9?uI8MTG&@?&XvO7_AVH8IVH>{D7VW#L zy1aSi^!r8JeD76twF9a_0PL)~@8(St85tQFnHlNGVmy|v6NBrfAIdf&T zd~kZcJo)%Ach2;b3Fth`1=I7C#{j!3jJt?D_~7(bxyHrv8!)32Li>Q@ae}5HhnYS_ z*#H+09sG88QXWmFL{dRx9Z=SVf1)ddfMxhPX{bGofEr-csr?2(10(y5$^hyI6APov z>9-SNgzx$Z z`Nem}$k6_FAan)Zb8BD>)NU-b*Jo#fHC%lYKM+;eYZ|QS3v^%*Hrg*KEe#_e-dgHM z;});o-0(0wk=+&cc};f;pC8(tu-Tb{Ety7Y?DW&U@=sz}`9q)VRoI&ASK)+5{G>ZE zg0>y^-RRt*L6cs)JUTv|c;MNFX}ZRtAA(!r7d=Rv(=W0WU~2yAyBnv^V;4A28|Q9V zHLWI0x7RkixYn@U9!bR@akulXcJklwV`O*NFb^E>M1gynSg1m{5`H(}HB5ciF!kGa zwFfg-snq3O{o8MWbo;|QooL&OKjLh6swg~o|G_9=r<~4tQ!c);*}}$u6$Po_!7~cC z2^@l^HgXX>B2<|euqrbURahyWwfRH{-f8O!8g9W?)iYg)RR@`kM+n!B3MDXW&u7SZepBn|1Xza`CMh^mkYB$JT{dswGET&EZPpu zlA&B>+m;Q4$?&#a)`6z3L+6Yy=OzEOzju^DO^;wvHik?29$iZT_=uE>^WQu)ljf!-w)PV;4G0F@|UdeH{gQuFN* zB zhYh>RZLd^NyX<-Q>#TYQVYHE~?DnXY-XWBptbku%FZ09wWs!)B01!qffNhY`8us;$ zl1F)&0ZyDPZ~F%w$hVjSHE`q==>~2nV#NnNF=0YI7-0=R3jwZk+{_lTcTMdmd3B|- z_*Oaxtombqq!B@PHBEkR_A^5B1daJbaj6Mat9eo>$X8APbS7oYQ_z8A;{w@;(HqAW zZL&?kW1-w_&^96ep&EIKlJ%m1XbV}(4lSI(7$Ap3Kpp4n5a~X;`6;XCQQLFePwXM# zcq{SRpuDz#Z#yX3mihX|XxXF$=GY$)kGHK=B8vql`!KL>^ z3~RQf3T=*;2@O+Ax;fjqu< zWw9JSzg7NxV;{#FnLj34pqo1ee)z3@<@}AMa_ZtN;Rnm|05g2>z;F*rY<5A7aJl*c zCRCo`@{|E~61WSAB8W+gGZ#uZbE&sXt$;uz2~e`t=WAZOt5_>rFS+;JxUxZB_|yB1cR&;rjz_-qq8%2&z;^Q$3x+#}Mn z&JF};Oev2oCWuxx7RtHlrSj3`x$-xCN270Vyiv+yupb_Vu3mUO3FDqPxn8bMfjhWS zgxn>fRX!#)F3on*G3t=A&OZqZ-8m zVLLu)GnYwA@33*oueWCrxxQ3}xX)&Qo89XalWm?MEq1$<)`uO5;gH5`277Errh5Nz zyvh5vJ_ajWDO9nBZ?JX@I?oPf% zKR3ddnBk3|;jrwH5nea%Hee%t?7IhX|Pp~>+{q`@IdHcJ@v3SECVH2joji-sIjXl1#r&svj`lnX*8ltjO z{r=;82kAOLdqRZhl2cHjgm6@WsXJ1%r|{CIyCo|E2t;1t*9aH=QjOyv*07w8i@*uj zkP4Afzz|mDSI@5Ah|vsj)ZlD02z8vcyNW@sb==%QF`y!VIGbER=vl#@7ag#1>;-lt z9WX_>!M~jkg)`0(suOY>3VZOe9sfN5D8|vS&>?B^vcbTz&!an}mU%K`;pZzch$9b3 zUOI=mDYER3n_*q_*t%KYkS`B1rM3Rv)*`e_fTo|!(u-6*{(#FwO z@Jn$_LBDi^ZMbafD!lHq0;HP#pL4*sc`zC(hwEbb8Ofj1QB3%-+0b`m$+ zhpe|rDrHX$Tq@TYM?P(HqY6fON_cu<3Ao8Y@!7DE@~g%as><#xw;a^yC!nkonf_<} z-3u7qX@3>@t33!(+E_T0%g!Rta!@9ve-2WWTKT8qX@GOv$A*_U4he;uXPvLFQEnXA z0ZhwGxuP;Ljw$imBW`fxw0hEo4^RNwQAfoaN<-DTUNRhF3VQ%h+9`O5rwLvVrRGD~ zX&NhJpp}~GkhRHp1oFmK;WIc`2e-bF!{xOzbLH@%wK8$|Ksk!0({z1?mFKdjsF7qZ zAE>c;l-|;c71GJRiJFO5WJQR_PlMR9i}oV(8aJ3&h-eVR;d#HrMhP1cg7AEYX@F?g zy3I5WH7I_e^d`Y|@Dy6qTQd-}ZD`gWI2n<$BV%#gPXmWg(Vj?mj!~!0fCdh%Tm=tQ z%S)nUzr@f!I{K#E2|I@J(~DhuiQ~N#uF_{l2b%Vjqa%IgyN|JYd~ttSUSKsn`1W)B zjBTU%#t}aDLzvX*p}j&Ci_&_c%&!cUD>GbHd}Rp*JjeIA9f2fQP+YG8f0MpOPn0e0 z9lmHK*{r&!9!)5cueXO8&FAVK8{$iY}V$S1l+BKXg-O;kLHe6;GddvG)XIWtm z{qzTyVQ_L4{~-ArVuGU|H+kE+EfwV7e3&~uzB^bZr-xBmq{qhSF>`*kiMzsTAClk+Y3b{rgqX+GHT!NkToc%Q$vP~JRoq5SBnN6N9oDCNp(5zL)AjAQ71{~w~o$oyPWO??{(elK@ z``P(GErY+8h!0uI6jkrd4_f8?r%l-^`@z+slh>D-j9X!1!MYl}g@;987}d`1Oc0hk zZ_qhsM7Z2ZlsPA7$m^Taz2z6L&6a=r;%Iqj%sg5Gz2&K+J>{E}(G$md%BNFQ5O~2U zFQ<|QduGe=LxbgMb`%}m&twi6cE?6FmpMl05{mh`b!)7pi$6AW{4oKd-7<35$sFaz z!9-;Wt^VupUn)O-{%9FHKv@xXi1K*iXyHQawetQa^JS#}VBjEcg`z(oXZzz3ME=LU z5?uT-0Ut=im8fS-#xOZ_oym#Q?EW~z0TZ@6J!80KBA? zyQrMp6b8}rsIUNNILn9$BCThqhr;`B!C9 zI1&fkJNUr$ZDbQ3!ai_`#6IWG2JMM?jE9`mbR$jMaPr`2*}ldGva6S)<`A)jN3M#u zL3nRPU#t-ZnT3#Z_<~`=PgIqx59y~}WV!gOACmTbX8$k3)3`U`?PJ!R zGKO#G7Ju!I6O%8KKAjxnt6!I?cm$Hc4j=?+w=1DfHdaRY`H5OtNVO-i=UnB`vh3 zCgj`jAhXlG5nA1NHX#Fag;CSFyLYh7j-a~j#`ZgceT_ep6?UV|=ghR_%&5~wS%tG5 zbsM)($}6iZ`6y>=E@7JnK6t?*iEnX;CbRt+9exE3CW2uP?CB5ywsFn$LbQQ{{vDHF z+uii7f|9s442-IyBchwb=`LX(y7ow?XqaXrLP%AK9XdD1o|gY_Nl6;^*cZ zW!Y|bw`8($-0t3{`3~p&Y;Z6g()BMpK>B9hP0JF#JsiWQ5yJg2(ozzbT+Epofw$yX89pcw0hMy~+Tyy83H zDOK(lhwYwC^7K7N``Sck$W4AEWEMjHnnmh=Es$CmN&P5$Wnh+_Weg-H1)L5T z>R&7)C^Co8t8%2dI>IzWVBR@k+Jd-(*DC8ILl$^VIa-cT->bk=ziL-q=J03~+bWgb zDl@bEY62<}U1iOiv|MeymW+c7#$Y^7xI4ArCBaKzp>p-?FBR+ogEtIaq zy=7djDQ1vY)3P-bf-d~|s}X(s@!6`9KoBNlM4I@SG^9Z3ib9bc3r;KqX%j@+@!xKY z)dXlf1Rbk=w+GBGgBqp5B+l;->~NG0K@05mGla{(M+$jd(mJ~a)_jO<9gfzBptj{I zxI?sJH;%PD40MbH&4`Hcm}{pW24bYxz_L9w0{*THKMY@d_p#pcI1@Ts98Ke-Ll1*8 z2l|$;6APwqMti+q0j2s97YWa|`dQJwUKTk+XmxG?g}kf&v*p}WsX@x?D2@}m#Mu?) z9OGYJd}^W`Ilz4dG59x!S$Yl`;}USwYmCz$OY8k*a)Bd-PH;i+HI&vITjh#$Cl8!B zaalg6%u{aAfCQZY;mU*ZjfaQ0*!EyqScG#uqaRO|=iUJkM2JGil{{|HUs`3-WMOl- zTwmBMubo&etEahFf#u~f;0&U%Bg68bY_2UYm-jC$m!G~dU4HoDekKQo%037x<_9>w ze{5*59GN&=9-822of|`}y5}-vCKg8E?|obXd}!Y=EB+_SaSC9V9Xc)OJ5OX-v0PwQ z;LUTTymx-ROrs(9y#95{SG1FFz&A+{^mY1Ack7>gEzR9E;_7(s!We8c|6=FPoEA#+ z76F_z_}$x+<)ud-E&GuXW2|;?!NV8}{-1iB38!x{Df7Dpj&_2}pw$+;6?%J@%b_FV z<@loq%fK+Z6{wfqZ!ib5vcaKDGO@6{tb)jNq*btKl(G0Hh2+dU7HpNWn_ccNCqA0x zD5wK*$AroY^KkeGJ3=1jei-V%4!IBnJFuM~QLf0^>h`OVM1v&i5m1(?v{~yJE%WTa zxiH(JT(6hoFYPP)IKnBhNZECT$)vYVlZUwtF1mK261gWZ`{&eACkJy6kEmEDGSRGy znMvgxUKohzn-~A^CCG#=>o_0HcLdJQ#>wzCy0DF@Np(j+gG)W!X_lj-=Hq^aL1u2< zy%AC{_#or7k!aGct~=k>zWdT&+nLZEzAH7FYPoCZpih@ijnn1y*{pj8=oZP}Z}_)J z_KR}v?xWs0O^ERorANDp-#L?a5dv;b0kSQuq|rIwww+G*x?_r(irn*W6B!rER3(FL zAby6i9k9o?Ndpmj3grRvCngziccGk6jAm}>fUep71i9)5RM+ah_wb|5kWuEaLmP;_ysZW)sLqgmd_p=8BK%{S13fgq5xMWcH{})0+I$5 zfSzH8YPNAa?X+rQ0Vfkkd?K~@>F|Axn}B0cXyhYN&e)xJUxW>eNTxx;Z1W{vN!N6b z>{~{L)QQYT4%!6D{4}wfHdwdpwS_P7V)p>=44q{l%v+fnuKH)HmXZQeCy>clEDG`rY_cr(3)eR>L1V!X$+4 zjv0Qt+aKfCZnXKFbU@lz!L8_hUe?A6xlJBdzlX3vQ1vrNRY=8nT_LAn4&hSq;jLh@ z0c)d!g98j-6m&H7c=iEr-3B%SGGKI;KN{H-Htp!Qu_!>4kA0kXEPSHJOsuoDZ63tk zhSmllp6Hmks)2k&UTG)Up*dkP=Y*6Od}yF59ip0o~eyAOzq+y3PQak9_agwSr;NEg+ zW!qRbw=k4T#Zb4q=mfjZU zrbK5O*n`LoSBBBbu|n2?9BmI^V;7c#)G(&g$UE8&6?Mzv(I8?=7?(5i9Y@gtz=5rD zl>HI$Aead+t_@6Z^|%maqb{D`uM#pY&;`G_)#38?skt&UkCt+Hu)OflFc-`AmI*Y; z`-$KV2;&BQP`Ji`S{L)qOJ%zf-}V=cG9k@F(n=@!T%|o%E0kH3)YCVJBf=PQk2T;{ zkZ|(Ha!6Ti^qO~F#5vua$d|YZC)#!3#D(1`{(}g$-_i6>0_Q-?Hjp6jf%hkDvhr&cny(Pvqn7$P;)Z<; zWdd#g2rJb44o}47l5M0%>sZdF>12oCq_>Z`n1@y#=W^)SAV~qJ2UI@Sk zN6NwdgXIbCG~lTI=$jmWn4K5j-zu-Xy;@rA81aZE4vHdg>t)~JedWOMLuHML6ekXS zuJZHlC**<4kNcTv;8Z6PbC;6vs8fy1pjJW$JvB-Ujy75yDxaKNiwPa+KLU}Y$>>37 zc$~W>4sfv4oV;ZIA*^>I#L;YEgurATDay|>RDL??qKshQLEI~`flZlpWpS?j@^|OU zOHX~H963B1{Xyp|*{D#?aW}-T--gFlI9^H|EpObNIB`|*Dy|E@fwNBet6-|UN?;6u zEnK5&0)!KV)}e6WhcIDyOOK#K+cWBlK@>f{Emx~|)O*}g(s-2CJjXaqQg>&A$9gy# zX%pN`Q&~ck=+pb1GEjKBHb(fANr=40S>BHC8sCkOM&(J8_G^h?=Ydw!ujWn!TZc^3;DV=|srlhX-$aP!_P$B`}sP=_T< z>-;9WQ~S{eC_7?D1CT>OiyYAH?&`WSA}|WX0^K|2i=fIH0(<&GrL%pq?Uk1>wxz&V z-{Hz}+bacLh$`}>-o-TS-?`vKqysi#>ZFS3bQHTxC`9{6y2{qJfKzgsUWCS(bO4;I z_r=vV6dEE!)yqV`fS^Igh@^CMT#am{fr&PkaGJzJEOoaJ1a9E!Le$OX#0$fM>*6SHUY!8>xVRJ2wgJ?+-$@6aG&oc{nuzKDYK!WKE^V+6H;EcjJN2l z&d+kp;fJ4;GiT1)YL_4W_}zXJmU=wa}H4w5HLh7oe*bMv-l|D7-~*5YkDZtDe_5auw~qQlRBLjzqeezt5PZ&+#HMrZur|-kYwvL zY8%kAR@LxteoVtgVI9u_k9?3XY^3?yZX&`qr%gJ7Dr{8Xbd2GAYI%115YVtO18;d{ z=f&WbbxeK$&p`3&M7^y|klpg|c7bgSP2mq|0@rOc`$J-Lf77m=5- zfhQDF9r{MOgv__hES;u=7$o&E;ipPdp&w2R>Nbr zN3&$WD1X&wnwq9>o;W6neh*qbC&$O&7nOSJ;IgC;!k+lU7uuU>vXu^_1ZG15o$U~7 z#3hhnNN$RwOP0B`cmDE9x%T&$%Ab7mSo!N8>@R=$Vk(nON5!Elx$E|ACn`dDiIQ!D z)yZqf?qTk2urnVGHR(p3BA;f>T$%rwg$*ui1@{ft(<|eU&C(9}EuX6a|p>KHqWeGVsWB%-s* zrZ2#FODiYSRX}q@BKe+QZpCpto4}^j*=3HB9bDp4Zetpc#|a5}V#9U{dZ4y+Xhv_r z0C>Vv;_J%x#~&%>?KAWh$j=b?N85>#d2wljodq1#L=;sX7P~txoXiqMK_xHhQ+X_p z-BJoU_2ZO*ly8v}^FGE=|17MJ%Yuh(r^O1T%ewas073=#DDWNNI`O~>F|n&btR=My zmMxU=eem^0vkFw+btSs_&4mZ4ocAai6$C3h`yh-HG%6l~-T+*LeixXqm~FxNuWgho z9HH}9-ybh8euD`Y=wffttU8IIWQ?jrafJtInQJxzps3Cxi#VmHoTs3F_D(53f4!8C z&cbWlAhr2VlIif7UA08Ps zV#VPosG;B??uH=D!tzr2FTb5FqwESe^uzJk=^?C9s>9)Z(50W9Pn@OX4gn{=R=MoB zZw%%3ft*xuhlLX|ll+`Mhkb^;a`UuzzAzWagvlmHHbJ)_01nVSDPoy}A1h0j*G9|L z>q}gA-CJg$fp;zFYhJwLV~`yky^BzCL#h(rzAE%@ak0Y!G<6b3I=XX0F(Qpz#p(_# zkG+~_Ytk~uKiSW{JiAdoWf#b!;Cs-kBY@~>PoGR}mA5|KDvN7e!AxBd1MgE%X|M(d zuELA6>#N)$;^ZHOAm&?=#tsV^tk!2`jsXN?;e@Fc-a(7%zWr^|))9|&GOjP{P4x4? zN$wot7^I;x%?=rkj5|0$-`1oINjT*jR+E;}9&IL+Xg4~@;FfU15}`9(en0qqK!L9w z1!^C4FEO`P)Mqc@?t~R-R1p!Lj^%t12GnQA>x0}E!)-Y6t$g#oCFPToilMtwP5?;p zGt8ty+zF!}g1`w!FQj)oyX80;dA=>Voe1s0x8)HEV$(#HnqDgY$rpIqrn|_`MKCVx zFnqheMq}o^KXG)UxHhy+V=aE0<Tl$Y9MsdhK!UKWww4ZJ_b zF#Xm;%Hb|aetQzhAPFf8FuyD84kHDWU@ZJ1XqO+~UL1l;FrHy6oQT)_?B>?(|G@s` zqCf~8D>wyiL+D`*o`=sD4Y!R2joBJ+gqy;4VR5-!xpJjUO|p7zZXpEM3c`4efu9UJ z#4PLRr~+uD>_Y%O^4LS=;GqL$XvEcYqyuByIgq_h|-#OREu?6M=)$K-+?GdmD=5Nr&iOcC_46|@oN-p5e?oF!K=Wu5J(GnNxf z_%;ovJP-{H17u;V;BrK(yDLD&K_l68r8MKa`$t%ogzA%<3^0w^Hcc7pvgL{bzU2!R zpvG38GdDb#)!`K7MmqCVQXJ8L9^A!NG8@=VxsO~8kf@l5KbcYW*5vGfa zB8)8DMCDsP>*7t}$XiXUv~?f|(==qqOftU(98rBo7~i@+FrS?Q640`-j5@~B2-0HN zPU2cl$a7`7QE=*2sm$8nk-^onZ;SwKK$5?p~;`MSk%J6oLP|K91fva&c`&YoE=|N6(rSQ$Q0##t@x zguH{H=(h7_sK~=)I_WuRjkZl$PDXnf5Q;{<@U1E?XE~nc#_TGzPz7vGqn1Uu?rra; z(Uw1rl2pq0Xfn36)I^4X;qWxWs3Omm%hG{|k)E^|WDw$l058aP5be=F*H}q@_S#~( zxH`_oxf%RAOSs?uxRfiLm*dWZEIf|5igt({1jzq{Yu-5zpTU?r7G3eb(KpJO^M}ic z^Q-_r(kcg!k8q|xWk#bZe#SI^Q9@=2yzM$T*`vxrE;2_o%ralDtUq~uvz$0Z|Hqqx z?YQ91xQX=kl^fjj{So85UxM>X&y;d}KO6@TX`(TiOe*`&ep!CDiMFNOS^$T#nw{r5 zOmYUz`v>sB^c06Bf!qLXxUg2)d2xAVr2O;*XX7)y_0&ULNDa+QCemusv)ks7EqRi* zJNPE^W&Y%ISLH8)|3#GJ7p6BjYo9pun^rg6mA=r6#@tioz1moriG*Bh) z1i@I)>GbqS`GB%LN}4Ly=gG)v6!!0&S}N1aOpKXPc?UjJF;Gi7O2tC4;K`3iQuT2; z_Zkz4^L)R3hO*=Mqi;XUt|&rK1l*0VRi!|CG9msqC@%-@9$_`Jy0855?Rh3H`%zMihf@CcC&}x(^uf6`Th*PD zl9spCY!liqbMMAA?r3=b!YD^T(I!Nu8i3hUM9pi@~vd$Sk18fV_3I4#=p@5Cdm&Ame+ks0h{~gY|e(n~3*MNKA82!|HBwcsk zb`MavXApOTZmltQ6ZXysP#e$t~OdkOe_|XR^%cW1RlpE+7mKGOjYi(~Gqcf2( zLVL;4bO#R~ERQ_#NO|^~PsD_*eoio~tH7u7`|VgiEK%W8286H(#A;6NjODFD)bg^w z6szMypP?)xFCZX|l&u3cA>S9L5(8(Y_4;&n){XD>X zKg!`@+F4NoPw3Ux$3!zgaClLmiByjR(fC&*N7Y-cQ4?i6&qpZA<=j21{fqf8n zV+cdKB!4821}=ES#G0M&&uJ>o?J@$MY_QW9?(?ShdE@WI>5Kk$CvO&fSnfUfHd6#C zWuGKTFjNI+;Wkes7s4=^En)0+B~ zjtNf#9vu>53e8;zey_7MZgFn8T)J?TnP`PB;cOUz@4(Mv5*)%ha5?_)5oUUy#GV5V z#2%sc#$239weB!y)}{WH%D_fT^ zfFbx<)#)&BV7QMX9`=`Gj~pt)bUZ>)XPE>UNW(vsIYC?BJ>>%Mom5(;L5oHgP0z-8OGg<{}fIrzu{jw)a&-hD8c zkNW`lZb2%0ZIQpmrk%T8i0!0RH+$rF6Qdn5i4)SJ3x?jh)d|ETW$hf}XIKKsSMgz5 zd}$IETqx~vvM%tCF@z4<(r$f;)lfH;Q^tsB4}R`I0clmAn}c>J8Ko6vU>o3{Wu$`E zLh*8ARewYMOJ$r(oF_)P)D-1e3)z)N1mUg7q|@szx-ABB$~RYh4-?hZuPR-G=OU=E zUA^w~tFRLje@$DtXxuFiY6p)s(hq%G6VBD)?x7oiftsC6wE~X zt3sRe^^w<~qIkVA*D7buvtpVZ3EzIYw>dW1TVVXK+(B@LU?NPSa}y^@aBhoWr6Yf5enGV;GB*XlR93Y zY#yK<=vg-4ZJv0m>=$n*aijwWE0r4z{!U$Bvg3SVVu7*>0g`lD;SPp174p;TxB+o#uAxWTRk+zsi) z#k1^6*+5zR&s=nTZUcq>u|DoMh?)@e4Y>r+*nvSTmF&{myvR51bXftG_ie}%Ewl<( zS-t-Lrzqz+m&f_DRLWzwp^Z8PRj|6_(TRdtBCp+OmCHBQ%EwGHJa(O9m>%6Mk8w28 zAqK@m(Ah~97T(81nk(^_NoR>|7Iqn~P4`gVJ>|V~o8>BF<7HR)(?_AALRV?P3O?$C ze@>FEQC1$&v$W}POuDY0*!V z22@Ajvm<1cyB5x@mBpcza&}=Dw^Ghr=FWi+d&_id9|L2imnf6;Nq?dtDuFga$cHd!T<$^{G)hKVdfEP{Y)&-Gh;HNj|mYb$haKg zB1~z|c^X1}c&dk8MJUb4|1h+00#cR7N;z0ga^J>(etWGv$?V%2eLQ z-dGKt#6ty@9J9$TsD-UV<@cvJc8NtlPq1@gkO{aCnfy8fFOQJ%P4M8LCF&S_0gmho zVRGvLlUIY~^)q9oZ@jOZWEcjA|b~TpxExfjezpFsoquldXlSaJm zF}*wMPwp9;&k*4*kgh_&igib0>2w?8bi3JpDeI8}Obo9qu9a&Sr^;);dZU~^d8SNV zpW?orrLxA(Fhp}GjGb5*Ko(3KohVO#<0+bt-ty=Z50$ZTE}=&@xGLYqUG$<=hjIL~ zbx%&Icz5H`jpM!BV^Tr7kf`!Ej}0Py++rL8CQ7atFGPc6OJPVS73kT~el62CTc9Cc z;JBM*`b7&1i=q6UygG>@e74M^5O>wO3p!PZJFyV=LoubbIFWlAn6#%`*hSzLCTVBykMR6hevUmH4ydd@a^z5X-3^!a z`j}9)%M`{8ff6>tHD48E!ZH5#3MZU9nT!icQS$er`0s?l1OG|^OV&SI;W++?LCO{a zq{o>@-Wv?|mQd1v_}0he?Ke)8kKX&JT=OMLp z{S2r$BCjk>&y_PLKP~^)fB3)4`STYz`xw;#ux;p75Uo3UZU&xz`*(j;o`3#-4W&Y^ zR=s`dUnbvBu0XIpI%C5F`^wynmGb(pPn7@kfBqAz31?}LqRT=Z6>HBM_kv0kWt@Ak z#u_;%R*oDyRQ}>$|7rPe{_ZbX1u{Y-OTj`XCz5f6Bp;dwl6);U|;iqypuGQs{1s&9vp^0MglZA14&N>tmA@xo)NNe7`^c z+aH%7{p5$`g~QL?4cM<-m?*X^yssSY_kwV{W3w|71lT4H;yYcUE7vp~(QX+xdMJpZ z=s5B1n>x^eQhBn8Sbd@w+O(0lMWWF-+4)0O2!bfJ{}mYUBM!EU;@@3NskH5+A;<@2 zk0?MHXYV9R0mW&Sg06j(cMs?55B4r{IqO1sZepl>)S5?G$esa|&6Z)Opy=q*cTu+3 z;q60_=V_)M-RG)ljnYa9rUH#n5#1m8!1S~masaufd@^+R1VlefF(&iyFD#EPS!4Hu zQ@d$!rOgUB?nWRb&p&6ea9QM8;b!T-|EGoW!m~Z)FaC6}{N&HLD3@D$_kp9VUMCn_ zA+$~&df84ED}VqV$KZ%3ibJ_n#pNt8=U7?4>W$UN*Fem~MX+tOlRM*@dp`{lFQvMY z-T_MHJv{s&oOBVYisYI@y#m=7K%f9+TQ-6{+88fCb~0s!RmY!P+$^8`hvhQPc`chv zkgswVgnf@`4)!JL;oGvooiY;R&>A!G=3nm&asph|z0{ae6|9LEt-nRh37 zJc?*x14VDofjA!L9E#tUPrwtzT_>(bY@sh8K52&1Q+n`d%memOh<1=?41C-zFxNao zw7aNwQEj`kV4X=373N&*yfI$RURf`%UNRp<6iyTv1q>}@#r@D*1@;Q|*ty8^{t@Um z$O3~|`T(11{uNr3K`$^yLK*D>1yQ1e$^WK#c13uc9bg*xv+{kOPTL|1@&V$U<1&cf zPc4_hx0cI6?gDshVyx_Ev4WEd``9St0(bA8o?qQ67cX$Y4igC;=j6)xrJnsv@O5zt zIIn>ZEDCNMVIYjm0I$Yn(lG{Spdu`W7Bd)q-{_&_G^{ z%vfxc@hVCMC!XxXu-v6w`*fqcd2PPD@(HybGO%u@T%LmOdsx{S`)I&>gG)|;dFtAF zxxggKuioD*`?*wNWwDoCKkVKp`(t1)eKuWUD_>bN8Xi(d?ha3PDh!ZkkNcqnm@HsI z4cg6KDdp76dim+Q-k|`EV6;^x4f=uWM5o8Ktw00rS!PGa{_^2fCX=SQ$oM06wt&|R zg*!7(0+ti>jyg^MNVCD@k2_43`VNu}I2PjrP21oKg2wx2ojoig*)gkJI(ubl! zHdvp=si*tO;#nrsuJjbHqkt}@T!Kb3vIBe*N(EpHLRafiAL;eOQ}YB|2w6ykHlt(R z#gIkHDeGSBday21%Dqhb4l&u}Btt(7U7iGRx zX4$efd1X3wD+~<$S$S&oamKf_>M_=|&&HpP0sWmECn!C{g*=jn?tZ(lKdWh{0|505 zb+p4X(KFcSoFk4*&>*P&}F84%gy}pWhM0 zun|h0iWZ8mTO0e(lzh;3jdG29Em^cMzJI(t`;BMH#DPN`In4`rIDX4ZX=6)gB?l7J3uitC#>Gq5%R49DD}VZ5y~G5=iyT#OAR1xAs=m3|q)*3P zWsrl>+-l{DQI+4K<23>wyz^1{{jc9Fum9#vR@KkaxO!GFFdTe^xj0Dv1sxs-_||{V_jBjR77bl%g{^~}MQCRy z2Go{0WOKee4ED5DTJjZcjeq!7`);&0FpnL{rZF;@}8zf27jzx zMw|3&yd#g9gsZ?EaC_uXDC(ix#?~-LsFp(m>fFp&1my3_OFZtVf~D>>4ZZdDE zihMU~9e^KI5y)@0tsdRe4}NZ49zbd9&I#*C6asn5%oEn|e8#s3f|q2HH#`t)6<#;E zUYpV)0t<(S_FaDQxF^r&KR~2)Xyg$yPW-fp8+intoa225?*3c@&a(XhC**oY$qAD? z9v^@Lez3Pi+FNCmLF^J`xd?rhh`mPT86cj0HYY5cw2`hJKgADjdDP+%X^5J&%DO(n zz;qerYMCa%rpa6%=~(syFg({13qb%;{#$+2JU#8dpzxLw!=}sM3U1yPG068YFy}6D z4+roT$%k>=(P}+)QH4-*{0#2Q3Qo?gmf3l(MrLwp0GXokxP%}se2V4;7we(%-K_+k zWX$Y(@TasV5nSO_`cWQd)I-0mXWmlhyV8L2$h$cVpJom^TE9HLYHr3G5V&iDC;GK^ zd?P5|ApoE~v>1RN=aGq9DifrK$~OF+=(5g{RgM@+xz!R$;zZ?_j%0j}3CJ}jWf!1f zAG|O_IxXr*s(KwTjn(a)VczzTdCwnR$=t-tm6+KcSPf%BANQyu@(%oz@GAn5u(?ck!kWC#UYR!R zE`W-hud?axCw-Ok@mX=d>-@`4wgq=-?j`J9g4}~(%HA)MVX42!v0Rjv?eXH=VmW*I zd}%Eq$Cb;3*kq9bRayeacWRK++G5iA%qM5dx4-)W?Gf!8_^aGSLWN97WvcFkr}iu1 z(?o40_4?g7-{VVpW12p$kdKoDfKewEEayHZ_H9u>3Byi;e(o+{%sqc&v0T1zv3zji zqjKt#)0_fvqb$v@l$E)aIAYqPd7}MQxRQ>TI&l%O>Fe67tk^f%Ghnu-zN@G9Y6!Rm zXGr$7auA#-bC2KgC?MPLp6CaI7>$6ZY@C6o7G`pXLfuUwh9evkHM)-z zHpWNGAjd`x(UuRQkazb$?*2{KsTZ>k>vukx%jVb21|7PIk$!~kltn|I*m;Y0+(Ai8 z7cCRYZ{yk1^@`PK5<-~K)cYdR2kc;8TY`srikz{EH^2fQl@2=uG= zvpDCu;U~DG;RtYOyX`+?I`^WI2m2!Ap}ji;n3adIgx$VPy6J}wk6kkEhFC-QGsh82 zi|mT<{)SkbMgMhvZoZs7d!C&&GvymhQb|+$icVY;13YUig-VaWt^+ufllM?s`Y8k^n3lcZ=$R|QBJ;phOOpvjGJ=MWk*0^ zZJgkOHeQLJV**9H+~cFN#KFK4%hi_H#^(__o)b8I<3{=G|Mt(zA&y7#!n$M%nEpaLy z+-TS#({xVO_|`ZuHcPoh z69)!^nozV#W}Y4f`xih2kY8(9wTmR~JM+RrYqAF~|X*&LGP!BA(q=1yi zL}5iKL|G&y(>cm19Q>i&qKqxSq_u;GXuB*gRu1naDlc(d(BzH5a+)KOHV0SA zxo1lG{u9IH2T!@`*gOMAB`Oofp$7)03O-!o;ROa9W5QwV)7)9W@RxXE(A@c8fWVv7 z!Nt|gc0R>t16;j7qLxF7`%no6?F?0owiy3bhsaOT!<>l^YwUmtejwxphO12StbvW? z7L(Q_Aaz(E$EtVK(`XEXR!oeG91G@A^b-%dJGa8{1c(d{AsXt?oCGkpBsl1R1!#*x zK*_pJdD#{wmeyET4<;T2A(o2e82;-BPQVXku%XO{fl!16e7p`Wxjs zh&rhvCG?X2yr7M*7z9i67L!gbI%d+qe!wvKBp1*hn4~9FoZxfyc@GNm*!O{Zi@QDa z7q0g<^^^ZC%L15A5XpC;0|paU;&b%?@u4I%=wD`X0;P7#!KE-T=G)j#9(gp76E~(E zJr)Yxi2~iDyhzktK2FeiY?c!c)|U-0A21TnP|%n>5XZ}=xyGD=p+>kRTYVLtEpa2JA?TU#U>!FQx# zBGACel0o7N-z?jwl;+ERFlnW+AG*$@k@6-88+=7(EOVWx9KzL@O-_8`z6W;O@VLWc znf`gmBtjc%6nK$;Uh=S$I@WI}5`PwPmSF@iXfh@4;RoJXZz+3r3&>Y-DKdlqWy;)L zByv-W3=di;Gg(~%;-X-?XGtuCQ+pB-W!vsM!dcfCTP-)`Bwt#2T1;b zDInWq?Yk2q1gv#0g8C7H3HNMAqQ`;^Sy^HQ^wsMqFsbhfNre<)Y?-asIsWIZETX=z zb8^9DF0%EAAeFvbv|q}SkO4&1%~k6;{A2n9C+ zDt0fBSMRCp+vF%bwxnK}y2hyrXX04l%N*H!<tn})lPTUgDQ`p!;KhUj z-!U$XjQE3Zf{a)Cg=eJWPk!-Roe6JS<#C6$s~hN}{0lbc+dK?7KA}T6d3Cn@%TIq< z-h1ccGBeHI#iq#;)HwH-{@_o(3;yHqfNi3w(T3Ab505fIGP0j?u{;vzs1KwayF^G4c zOmM7|$49yVR7%>dQ5P8fYVneJ0|9a0E7N3XufToH;AAg!4>A?Z)4&NP!yf%Z>ED=O zHtbba=vCv@U^PsQ^V#3l#0vGZL%CJVy~FcXDg0sF%~x%-_6mb2XNto?7h?oY2w>)M zda=Cq+KKY>|NP5x`s1@~Uq_lL&~k*Lg@>35hIlv94(Cp(YEx+N*vd5n5=SteANb*k zPhw(Wgqc>4MmqlJQ7&UjcyvbcK?llJG^x3#NEwih&Wz|y!Ei`ZQ;g3d4~&qK6?LTnYxDdh!%z{Wkbl@j+m1> z39F%TG#Z(d>WS?9m?s;SW?F~{k#FKxc&;dJcg;G;WgbKf6ZoaI{T)FY|Mc1!xhsO0 zRzV~&17H)r!tgWPyW>5ue;p`Li`cxRQulVe(v{;TVvIMsoaGZ`N2gxH)xlrDt!$9L#Z{H}_uPm2oE)woxCD1pI_Ln0LB1qOd9U=wc0G_5c9u>3h zr01yOGRqMz?$}WAEIy{>aZ)v+@y&r`h}JUz;?QM;8-x1lW@3?Ib?|LOJS^MrPbU?; zG3eEcCob3p9|L6@94&Nw4>R~v$sGX=3~e0jcp|Q4BQJPQg0Tmsn`I-deUo$XH))S} zX}2d`$T+bPN2%a#IEPuT(vSQH{z<9D zPT|Q9wO8;w7EQoFc@BC`jpoNJNqR+COd zAgYTzhM|~Yt>xl6>IcnRgFx9(V$1409 zs=hn!;i3#Den|Cz1B6Cagj@!me=Q~y=D1jQV~zF-S#94b`hB!3*6)~D2swhxp}M=9 zVV+$MuI^V%*}g=|eqf?sp^ylC!)e=(ElbDj#9g22R^Qz;s+gx+wo?2mr}g1{N{)zX?MeZw{n?+%4!)Q7o*A#|4AsEFi(@1~e)^A*hQj4Tw zXAJGW)7HELwh?SmYzTNo;EXBG?qYC-{1ue(@4w$ujy^hGp8w8s<%RD)U!H#UDHO$C z(u)!v<-6se5r#yt}QTmZr&-MV_8ae&S6=>YxyDH!TkIUP2PFu* z4+jtKD?j+*i)HM43NX5S2=F#CjkYiBW2zTs4 z4bn6%e5Hp3Cc$L!cJ@qM`IAQpv&U&&Gr5=#o@RA^bbEPlkC!n`!Q5{ zK;t!LknJoez>Tj=Pgs6q!ac2<4k%qMMi@IoUi$21ndUnJ0>p-MmGc}ogK2QK9EBlz z^@Y8gzUkBaw$lqlwr9P8(ax%fgdvhNrhyFPgwdd6C%{#p_fjWnN1y@z;Po}4fE*Lm zQHF0=j1~sA;R7f6Bs%;}o|A7JwCHEO%J`j*nE>; zOH4AHWJTFE&fb0J#B%wc|Lv3IPro%%j&h#8XWBc?Gb;|QJeJ63v3bvM%*6rOK`G-?Ryv{3rmdnN32!<=@Yo~Dm?HyXHv{LjvNxSJjlm&7`#DG1W088$e9{wO zz}S!CmE-=5Z-G%h%eEOBHN=`gSbyo6c{3NTM2kKLG&af#_zZIok>R|s*9C-O7?f^t z`R_Wb|5dIEJ<7{SDgsQ0!7CXfd1(p#SzR7F0)A|(JTpJw1x(^aew8bBiIO()0Y9i1 zUIhocOclRK(7-nEyyH#+GZPmN640_B|4!II;Q)ne*omY|8R33|RW2-Cvoq!h)uJK~ zmcau4W6)U>17$5D3`|*mo&J|NxsyOQcZIUf149^zVHtv#G#%k$zZE88+zKCwL^)Wp z=82sugi*2Wgo-%CrM!S}5@eoZmAos*iNC!3Pby&W55k)!I7fLA-!r)LqGOSiHi48y zK}tA*n`BG;@Wy=ufxqPOO_mM3qilC)#2|_ZCqx#^4pFkMLOJ|aps!%`aT>zB1mK1T z;xqmvcq$b(b!Y4pGF<}WW}UN$2gu+W z_I}{0s<2-e#Ewp(aLupfPP~u-))o^HZ{a2Fq85RJ{NN?!W5gQ~`>k23)5UUNc(Ocl z3^!z(6B$x7$05K5CtGKsnr*?VM6@jyt4xnq+Zf19XC2Y**B51OmDa7?zbL%+CLL+o zK#IcH8>m_L_6FAdgc5zin*aNmP&e$5Cvp!7Tc3P&`}1Kf%leldqG&%iyA<7Zq#{p^ z47e3gx;y4{0S%LF0)V4s*b0%+4KE3xIZgvEMYY^2_~;dAt`q|x%%7tAu2kC&hP-QScKzVnSTa%hyc z9a^(u!ZudCGG=gGd`kdnA)V70>i!sMZ93Tn^TJ~K_FT?%_Uxx+iWT*q_b+R@bYh%K ztG(#^;m3}%`_VR^9fiG1+;0Vsk(YINNJYInJfkhKk0$CSg0b}dw;t8JX?GDp)T`>s zw`HLpYZo%GZ4=*PYeQvheXxw6zj6183mkgklV13SqG7d{YNGhuEwq$ z(+YUN8XfK}M~{z{=f3?6cZV^7(#O$R1K{lg-mzx~m>$7p`n|4>pdUA6{|5I#(sD6Q zzwJ3*P>#2}omn*>mZ*8n{L&^;WgT)FtElVs@KpqH1B zUuFW$dt0CT#t6GZ=u?r-8hSZ*gv4$c9Z1vuM7N3Yodj--y0#9btLx4;SXvh`Q6GdE z^);Lx{B%*Eo+2xSx}0yeMx)l{oZm386#|o@+3)NG#v4EXaE(91RlD!_l8gP1N4MJ> z;noU%`;c9}x6fKv!0pyr0k*q7__}urApEI`3cgK7LLVT^{-1yR7gj{hvZcu~XQ1H# zAzC~N3&9)g0c&x$46~)f<=F9K<>;|v%mi~G-{KP660ek*$(gduS+E-`bQah<9+jOm z9RGSLbZ$#zfXT@Hm7o5WxP9fn{<}X%$hhs?b#D$@Q~=4N&iZ>DB5wwbJzR1(dF4j= z<;%Y*@4R)QoIP`%Nd<&rNJA1)z}VQuYDdwvu~Qj<8$aGbD-G> zRYTb5ikHU)#R>{W()~#<{noJBYCDagRNKLLL%8!QCz6Omn-D&Q1Ly zpm!Shy~g0b7b%e6I04haz857$L&OZWmsG#;+jltniTfntrpBCScJ-baO&k(@lmXUA z%a4Wz;S!CgD;1FnM&fbKI%EiW7iT*jo-7bE>~;*X?`k6O4j;2*_-;RWNmJbnBTn|5 zG=U!-G#V#6o@~H_j*VxKh#%kgU{W@k7ZZJ8zU&l;;FqWV7+M_EpH?;>i>$4?4 z!(i6=Fb$~eNT;KR$+pCT_eMAL@F<=ErxiQv*9Si4}IfyCFQVCk3+n zv{yl(9od$Y#a;=jd)i}yT4g)N2?dpMo$vu9Z)6N7(gng z^D=Rj#y;>3@-(tjvj-k3@;zduj+M<-ESn0UNqE{MyixoPGYImqr?IlSI9vYx&*saC zPd3X--#AeI^2OeAkjoQ>2swbPc7^jCaHh%9Rc072U0f@R+_Nyi#k;FCdLBQoa<#l&Mh$e9ft~p!69x`|ya^Mqa1E!xI~RO-(ep9^Ht6>x zD00!Js}UncMkJnnw?&|M!8U3RV5^|7ylheqvh@=M;y3iSE=Rx5QC7qyj`foKuSOWt zA#Fio3&V$32gjreLBqXz8nQBKa#4>X$5!_s`%H`YW1+;ldO;WQDJG<0w1(ShN899b^* zL%nOI*Ih8A>E+AI&?e|CtVphbZ}~>Q4Y)O-NS9CIIH|dYob>o8kHU&e)d%KLo?I(O z_Fv^Fq%r1NoW{vWeJD86?d#qRl#szY1Hzjf%I);L^>>98`hei?TZgSaHBSA${Tr-;Pq&AP zspbamYu}rJrAyHHE~DMKOzskpyiQ)C8gE0a#^26Z!IMqyTr5$gYwY-m zJl8tY2$nWX;Z;3R^>cCoXXR1T4{)652szG6cd8#b?V0Yqj1yzzOgJYNwwN?s;;bM`AQK*CV_4;G7 z6!_f?(#=1$Eo(sXBE?8GlZddonqM1q=-_9_5s*NpivIvyFp9xEOF<S98|&_IGe;V2mEXeQbN`sOJl+HEpe^8N9*%Zm*URofZ8)$e)-BP<<+16woF}}W@5~QvUD8RNY95JdAL0N?6c^cSnSD8 zzLlYkGTXOYPETDd)13S2ZVM+V`q@_joqH22=oJW1x4T;?nd{SiE|FOxTG$N9%3t0C|eh} z4UP4cZ+-8D^2n3N*&63|D0UkhJcwT(LVTGs*JjJ&>`J+MZnB(u|2*63E^`k6LpeYw zr?Op6Zsf>b8r}MEG{v$N^osF17G8kn1lQ(W z?w`Wg200Y}48WEbRyYFcB-`cQ3425g!ewV+WP~k$Tud~;3dG5&+zOZg8~8Or)2Q^* zY9T9_O_z+v<;~I(eEMueDEAlUzEKVzI~07AkpXv_-Mb`Snrc%ZUsZ0 zk{9JJ!_-c;Uw%`~!q%Cns=UBl7(sZ}Nb#~fwQeNTM;$@9< zNn~r>xHf6FY2OuC5P3nljupqQ09d#20+v!~Lb$!-BV*kp8Z*4Bi@o|A9s+xhK+ z)drC}V`>;N4*V37E=llT`4Xhk#x|$#t2w` zh>K+sZ(XI1ur$0?HbnU|dBE{UJv}pJxNoXVj4qUi53QH6!BrFzM0IJojUx%$)~ZnV4!;2FPa{B60*hUPGqyCQkI~t% zyhASW;hz&Gp=bt{{jX4v8mNh8R84$#gT}$nn!|RWc9`@HcjMD%ly1ts>_7U=A{IJL zv)uc5>Z5bz{kK0Z@4xdQmo#6A`yc}&@JoCJLY$C|6?W#jja=Z=ARxkxHOJ37U?82& zF$}?yL7IR;pe9_6>|2>j2P`i#)*g^TOdC4XCk==VCXhXHd67$d7r3N%W_kw2{f+Yc zcb_c}J$|f=>>q*Vu@5zy6dMSD2valF;aTzAFnbAwF1ub6xQ4Z$BS+(@+>o{&|cqk-I+7NA?9qj`Y5EP(;Tq%_W-`emNc%XtYXz8FJ)^OD-vz3k#^J=< z>O!lWI(3#^EpL=h(Cw@)v41#{%<>B>gNujRaq-l*m{?#n(&_+5f%P&50RPLYvt_Dx znLC*b>7lFafB?3~C87x`vn>`Su_Ab}AA0nSm4iJaWrRrvG$}DDW1qhkja(Qq!K5uq z5wQ&YQ(+cqkVkY}Ues*8cbDVRVhcW5VG-AQS)Avna_&EQXy9lWWoAJ(k4GXx8$k;^ zV23RnZu*IQhik^j0NOh~>E^fY-QjkAWyrn$O_{jYQ1>35W79o09b!l2-UWKNca_Vn z!tIP&!&$7#*u6Hv!+Q<%0X!&Ri$sM-@O=Ey2^8h$%fdXuEJi0yrByN$%Twi3_K%mREoAyYlOQ`E|K=Ws*ya zm^n^RAzY#rM7S)@FLBo3#x08mRD9{_BY^wUl{R3(1l!BZ~y>607*naRC()soSVlU@ii`Y+GeWkiR}3kT^sU{ z0xkzRI0qlo4NzbL!baN*j_nv+zcyLke*GOT)_J*{K6O6M0Fpp$zn61d+Bec$zWu## zmLLDskIJ76e6Jiiet`Z)i04SBgGW~-!!fb&oqw_Ilzaqk(13por9|hg-_;Ok#5|wz zgZDowr%#_IB5sYKuRVn04<9KeBeMPy@)UV=yX^HqG2BV zbL;!F&p%ZT9zGBk^|($Y!E6U6tvbg1>+i$RTv(J-od1VY_}Bp@PrV%Pw8}QXYnN`6 zOPr0kyfi~x_FssIj6ggWC3{)XfkRAc9T@K<^T76i0$mi4uVk+6yqE#n?N!rQ1rJ6I zfLHyn!r21~H{oaxaLq4WFlAFZXD||~_0&~DcF|(hRK#pUYQ7%4?iU4INmHM`t?6=@ zU1S4CxNHo?`oj7MTew;6&+45GR_q|{0*%-K7P8EhL2QXe*0`!JF4B!KwV<>6*pX)n zI#+u8!rsU|S};26A+GREM^kcO5vIO@tyE4$e|j|mfW?4CnP>b6&j4A5ZjxD`-FA}u z+&q^Ze!NgloZToto*gLP`_@o-XhK2BaX-lLbC*i_?Qht*!2Jpy{p8B{HIVaYrcj_G zOVgyB1E*cR5ES$PSQ8LP=-gQ%Ax&h$T8M$azq6^ zaNwlrz~7Ep_5V`5$+kJav!OJy({SgYU$GEh*L;0>ia`#$J1*#?@sti2*!LW)a`$;Z^>2E@y)<&gX3qMH0Wb-n&Uk0v{JpP;?%?l%A$WE z&EZ8d6iQI&V`O6q8bPAF+NbQqTQ|5z$joiwstbboda=<6f5TAQs@K}@T%1#DHTC=AwaebNNKZKnI-a1Ia7?& zQ#aUkF~{*YXUo*&kIMJ|?8Wl*^G}qq{cM+w$SJR*AE^!jFug?dsH)6+?Gd-p_}rF6 z6}Tj@iLz#Hc9we;=e}ZjM1(XD5K;f;h#2jKzYCc3Ff9Mgrjjr+|P8+jv8JcP+*aRNUZ@xSMH!;~w_0 zXmYgGSB?y@TVZ6P92uS{!yL&vz%I+p<;^mAX|nv{|NBMx;GGZ34eo^qWB=R?3LN?l zjP;jePd!u~{pRDPMP62zSeWHbqRAB|ei*Yjsj!Ux%v}eLKL@DW9^trv4rO6&smxOr zPA)7BtgvHae>pV3<<%Tdq+ZG6rR5@b{<-TY%Zs#0S`=i!sH32ypWpJ1Zo?x(<t95~8d3?s?}>p5?5GG0u+0AugWUc%ZttiDW;s^G4G zl2MZQ$sjk|J7MppkWx_AI1k=mEehB$_9EDNw?@i)Cr*_!XVF50h-@T@9S-~d2(&@Y zIsN9hA20vY|MEYShaZ20o9Bl)%7E=+OxMOO?~Q=zL8u{i%cD;|RQ~SY{Unsaul@4( z<^1Uj${|3ZvarFUAq|-V{7W34^ycs0DKGxXw?i+kP;)yOVjUsJJpwdlzxd}@%dgoU zcI5(R&T-~q%-Abzt7+JEV5lF(_r7xM@FV5=rD?)nt06k?u4vUU>WVFAWSu-H`NV)3 z6>T<-?iN^GS}Q+iLg80G`%NgnLqTpFqPXSmgEvo>rG-_V#qw|d$Db5;Vn#ljgt}{$ zaJx-ETMHHLUV@Wb&x{&WKnfF+NBX%$@8zHUic7FRX5xeo8c+7jQFPE$$$`@ke)v*3 z#>B#hAAF23C$5R{nEuXmJ2;@mk24qv(*%kOQt4n&By4ZpDu<3vlnEY9pE&I>x^x#y zGLUqo+*u(I`~hFBxjhg!Cu{7FY%+N?#4P%i^Eb+wk6GOTmRmIP*>VY#2b^s5#wYsA zlg~WDmcU~;J}?g`u$=<3ii(af(deZ$jtXcyN2m+}W5u>$zLZ|Fbqsch`~S1|p5Jxd zSfVFL-g}uTQdG1gOAc}lRSs2lrLOAN{lbU$X+F;1Hy>ubHEU+gym@Q&>z;~T!DW|S z4&}0CNtP{3Ru0UW^ZRWa+(1zhB~i9zUy$dXgS4@+0c-#pz=l(xI5J)ugtfJrc*Wv@ zJQ9S5UHtiC-YGS4=Vvg)bZLm*>BDOrK5A*?1mXU!Cg=&?e3glh>?39W+ z6Jj*jGJ|j8ZZy-xQRNNZ*;G|XOpMQztn%;#0=UpGs=IV44W(=F{p+j!s1O)Tqf8b~ z|9LI_&kt78eg$&CTbknH>M8QfPNhY3AH@O&?yAvWfuYC30v?yF0IE(IPe3drYLs@uffwyw&y+-J(W;;xA7#V-t?4CsP?Rg9|Q zQwGyyeqpayvCD9kU!sPA$BiNOmkP*KT@_qS{>X-xfqry9lWNZz3k!Xpr^(5+w0}3M z-3Gyoou&58UDKt&{h&b1`|bze`v%kY$7B1en&O=%)jn-&eKQ88jk?P^8{W;&Doy5U zS(wgTSYar_QdJhmuJ-`Myge2h;3o{9oU6(@1nDrmFYZj@l3b*ZecaztLolv30tMi1 zN;ExZ{qhi#_6gJcC=U1W9b+8ulDLKMiAnXHDZww{dL2jdI74qR{_^g}6DK}Szx(wc z(;xrvR$81w9W*=Cjp}AN7ZH?gDEla~IZtt@-Z(OC#x?D4QPIc(aqnSZH#&-1oAFV! zMU1B5p%Gw{KBxEVsH0xxK7l!Qx-YQta$$ZUUFUM>>(}PdYCwTFq;01qL%$X2ZeBe_ z0-~`15oKvk6((3NJXmA1Coap#U#@Z#_Fw*=-=-7CKTog!;+6Cd|MXg_BRn=-gFAzI zkejGW-@Gc*D8g#*J97NG-Nt^!tS!@@%uai{Vup>Flo4a(9qg#zjfRE#5GwoF-M`GB ze;#67S)5K6=V#Kjm4!6dYAHCS%sTV>6do&ZUb^&V58P9vEq>YuSI=%#KoxWI3`o__YvKS?v!XVO&` z2d1ach-*hCuOtb0TF5j!wQ&CYrMzIk%BiV(=ffHTcDl;wBtCraLttE?Qv<#xMk~Cn zgFdvF1BZ5}XJ2?SegBmg(`TQ2nf@~Kema-V15Xi2B+J{xv1lT$KKfgL4?-fo+e0l0z`gq6j^y8oZFpYAvCgZ8f zn3e&3SB$+NLkFO+NA*K?BBp5&ad2|x9TNTM$D6$}n~uHvaXR+lr|Ieyv`Ysa?D!3w zSn%mjhYlTx6D18KNFRT6Jm;fInPf7)*bVP)6&GppE?@0B=1$rmvGR?Z7^^zN%Xf@u zF6=TpOaSk22}{s1dPd>8GXuT6Ua|#E;}iyd3iRl)gRXyh`>&jcP>~0znuJM04!?)T zhSSSGelG3d@};H_J+R9x<%M%Le_gs{95h! z$h?BOo&!Xk>40I}h?OJeR#6W$U3 zb9^Zsn7u-OhtAL0G43fCj4`{Ry|fh$7*7cDjT8o^qcAO3hTu|lCLc?DMSl@>CMrq} zUZP=hY7+MZ5XPPVb?T6U`>KZJ@cvpgOfRa409;H^H!JGioVem{PZ6MjB|nx&_rQmp zq^>b}Sx0y3v=b1HBD0pjglUx{*lVeuNlV`{cLD%rk@QtRufEhd2K=Gw$qv|slQ8C0 zB(m`o_}OrXTd zF{+SQ&}l6ngXh>Mx~LlWQy7>32*=xz{xF+i>g*OB;#(*u#_kwXZ5{kwhy1G3yK6-6A!6yuEy0iz zcfewl6l0t`XN)sUwAJ{-y*d0TaVm$Hi_#LEx49va{nzP}za3*v%zPNtz;m;RYuz2s zI0VUL8W^r~acd)u@7TedbbmVh{K3@VG-WLd4`Nrd<2AJcxHB(K>VxpT)w+W8-M;e( zGFAa%0YP#{%UO-s>ZMP|PT>4GgH`-;>2Fp#o`!9sZY>rqlTaPBtvhqc)V&$ta&+G|OO0P0VGks)}kc zy}f7({vA1?a*)j^Prz`Gn<5L+M|Ly70DP3igUProNv3 z`qsPYcfa~=THu~i$MsDkunMjxfu4Bg@wAJ3BEXn6Ey@{;y_|ZE#kv*nZ7917;k^+l z3d&z--jBHi4Wb@8hhUDtS_%gF5`W5d>J)UtdW36x-kN=Z(eaCu2G*I1FX->0ig&E^ z5A>&zk&#$fSfm`>csoOxjLZ+w*AGSdQT0j7g&Rd^w>AY6iBP_cp}lMFlmd6TupqHF z5FD$`y4`hS$6D+gLQzrLV|8ZX7G9kJyS8sE1#G4Z?07$O=3-nbw9L_^FmlLf!!S!2 z?(j%|dg9r~;%ggcS7gc)88BS4IF&rV48SuB28aM zSZrW8i}fdJ7xpD|>d#!Ci}(840DZchHbN0QBo-J{g>>!mH99b+MG;B-hzJK%Mbpqo zBON(U6rqr9gg*N*0>yO@QFwTy}&ysf%A;M!(#})EM2z=2!<|q7n(m7e8T& zO9deh_`pd}vUrb~y08!zM{|ts{1=zgAAa*jI!~E-F_BQ&J`ykc?$Mni=|{hK9hFsw zxkQ_VA`>Q&=mUJ`7%dr6lwXxs{33*f18ohPSF5wL<(}QiEuUh^3zL5{EyJPp6BWVq zg^60DxGas65==x0?%=>$w(5Pg%C5L2jnTgW9~m3Di77k9Df4-l)rpqu_J zht4`vh0i#&TDL%Q-K=yV2}}p z%QBZGT5*?&L)VmQEYglKo9M^x4&)_x_c~jz)}4$n>5EIG@pH#r&STDlskKtvn6DPz zCfzk%3Un#(U{OG>VXMv)%+8jsyF)zo1cfPmldV2ZkOrU1egP!^R1WeHA`;kz)P8uc z7t-g{RCJUtjIRR6OI2?}TsGOidMy?fCQyNNX#ZTA97jOWZ)BEmL8vEqRE1OoJdMw$Yp`<%P4DxLeBjjtH|#=BLnB7oR~y)%VqSZzx~JextsB0E}CUdytt5-*r*s6 zv@(W?d+-qIjr3h2Q+w0!uB?+iHe&-f1hOVa`r`6+n;5M-Uu8TM3@vcV zlyWqeM`=<+!Dug+)J~^ke>7??wC(XMR@^0xIE4|JP8uOB%^K+ z*Ao$oi;D=|=R!@><%=vXT)4_+&dce_1%&J8uWZPn^F&%k)KeBCbL3UK;*?p*Pi24?iFI$f*1}cPU(8QQ`9HT)IXdGe;le4*wNpL6o)i(Q&u}{-6i> zEV=icWkW%gps`75tS&K{M#$cerOt}p5YGTdl6P>Q!JhhfdT3yhK5vMPL!9*Ca_?m{ zV7&kKhw1G%-bIDb=XuIf$dePz2?4mq$-c*)I+Awon}~%4wS_Ek*V>{Rg(zdP80or@ z@4UkzG*3(g;>7VH`JAI3x^Zb0S&Y@XcL@bvwC7PyPk3iaY+53sNn3w3F=r827)p_E z|IBZcZ`h&)kn8lNp<(jJ=^5`_)qjqAP#2Lcj*JZjPJ(KOUje?!tEAiR9kJUTy5C!* zKpyvPF>nqhvD=^GS{1^EvarC*b7M04*+gz;HUPU>u&(}g^dS$^;FEIY0+dh%Ig z;b3&Ukq$kwEB&Yc`lIyJGf$+6i3x=MT(c9xc2hErPpQv-n>mgCIio=nJ5M@9J5HlS zzD(OtyrtE}3m4;l2V*PSX&*F%TLs$P0+;B(_Z+4pVRc%QF)&RGX}Oc6vV~Ku;DS%= zIH04byDNl19=TMX_Vr7%={WaHy!FR-((?3D&?tU_)^sW?uPdIc;6+++e8Z@+$PMo*;^WKADv72S&RX-;%iZ?TTOcyk4Q~u5p(7?e zZ=B|{e+~SCK@%YC^pk*#9ZDo&Dl_=6pi_3ehoufr6gs*r2!;+6nQUb$ck5+*`XfO} zxof%<=u+T8r+|%F4wOv{uWaKO-Q-WmE5bNF)o4ZOY4wMFvyRM|Zh#?E`V1Af)l}GQ3iDQe zn@0EsI_5El#J)G=HjYV}<#=ykyRq+?0v2q0;o3lQ!)-4k1%++Bd!*Xlu5*lNVG-Z9 zna3u=u43YCldnc3d?Qf4zL4Je(|hTiKfjB*;d3z;4(8#?C1#z0*uwO3THr$3sVg&F zUVJ{)c7V&s3+eHlkHik?7~8}SX~!D;6+vzW@iqODXU6@RZ&exh6=N)D%%HANUdqlP zSU>*vPt*VP-~LCsc;*6f6t|OjS+%~7E~Qmz&xxjSdaiD4L4mOj=(5~P!59-9*16p#~jOG zF<}AqHy6`y{^##mXt>To(m#gE9#5+foy8_1yTAeZAv4-oAq9QUCiiLcsNGpv;*=X{ z*`tl1vU`xlE#(wz$gbw;bEZ~UU|F6`Q;dhFIT}0%U00#2!udK85qX7xeg%Zxm>)Vc z$cW_6QYhpiK+pYH9?P3g{WZD|QZ6hVq(Sag7-2p!SszV%`X{(FnR_=mMOw~#nPtS zi0_}gMf^1J4g5%qpwU<0KQxGz4(?hdip5TwKqVNOM%+9qwS=kPD!8g5OS)NiZ)PH8 z{L3cZn}zGT-<|?@v9NGEpcJ5mQZNc4!j;7U99ji>x$(5sMfKq@&t_oN;I& z`ws0&KlsrL>8JnURhTU|+{4hk9Lfa;xs`u6H33Us!zeV$h{zm^`X7U=qAb!7HAFr& zj*AU*Vna1P8aLyMoA?+#CO?9~*x`?Jxpg194;{$qEd7PKnUSU{^K*q<)?MSMV{OG_ zmgGeMNQ&Nn`)@2NydAp)YW8><0s4@@qfZ=8KmNrJSzw^UXLmm*G^0Z>*sVw*c*{;e z3i|z%;ah(0O3*|nf}84^1Qww^brA6)2tV1c*z!9Q#C^V$WYEb$&pLDyhKLJt9(|Ob ze0%~Ol_ziuYP4_`1)hVC97r#|`~u%}OiIrLP?3SJyEZusc6QqU?5?rsM!XjaM13rE z@lMN;$kC@ol`4a(j7@+lBSxIs`_Gb0v<>{MKR2sOwrT%wRmX&LEB||;&vz%TlxA>D zzqQ7oevO6g9`vgAcx)W{?qDJMsE49YeUvWEPH^$ro^*A6A7xWxvfv_vEX9ds=3BvF zTAlhW2F&Pr2xqE1!e&7u_ZGY` zrV#v@_Z&~|jR5Q-f(f`#V5O5!1ELSU>WK%KcYsnLsLt5+bz)nORYTxR!<7fqbFtW2 zBXw6S=77b4<>>zHV$_GOd!!X#(gnw^=~AFefd`NR`K*(dOJ*bQprOl;N{3dwc*~6( z`Frxomajl%f#5AuS>!?R2gmi^$)W(tHOuVF{GhN59O`hf{g$`?%%1NR4l2{w-WvNy~)rS!;95 z((=38uUJTgM>z8@LdQ8a0UkegD$SzuCmOoIRe>Z*N)uxQ!7JvA##~QdAVhui(!+#t zT!tSe=IF$6cYBTTNT>kLUSH#{nP@lWi)RzYa`NQK^v>Jwph@5y0__kt$8fBVag}Ol z9DAwjehp!+!?F;d7VLw57XH* z=h9nm{DsSdkEQdcFO#O}`hajX2vzlO0q$UkQ&113N<%j6|`WRmia2jBAv6l7^ zjHmr9DvZ^*4}(pn^?vRrWf9@zr=O+&_P_lfoN_spX1J3er%Z~`tf5+1PZ9wp8(^(Fmr$)2F0l~6{9!%jEV*9gfVOZ9^Pf5k zN^TC;SnjGXS59Vp>!I>LRgM>bDH+u@>UlT^tmc>@JKf^f_~01v1B+XP{cd~ZQn_0i zNZXKfTEp6bbXeLww)pMjxYW2UM(GCo22o&Rqob2x6}LXvllHI^ygm|5oX%ZgzHF8Y zBw0y!mDC;L?v!*jGLWR|cM?6K=*LzYf++dSGB69XfI_4euCkj+-K{25w7E6-;B`xC_0AjRvO` zf=i24W!l0Pr+Bx!(^0z&#$W0QAh894%YbGq-Y1!)!NDZM?vMU9*i8fW8#ZI*SUPCX@B9u}f(k9_q=3LAE39A7{aq zwS-d_m($6q{&aQqAtHD{*r>VUC`_}gT@;WL+IuwsmeqS0(kh^T?rkDk)P-PP`sO+q zygyG)44Lm;5Msqw0Tr+X$mGDub(~hfJvb&{N^Oys#=sufPFM%1S5`Y0;rnsxBYmG5 z&l0g0m{2UaLn*cn)1g>r<(u9X9sE_|=qAqAbbEaBodt*F(2rE{LwVa|&2Q z-%$d7yXObF?d1wlMlP0hTN@5f+ID-;@XxVs|0^t`w*a_vcm-kRm+sK#-odO+(Lh|@ z9H-i8zrhMB@hef+kqfAPXLfo4wUCT>lp_>F-BhmRoGt{=#r%L6n3LGe+M!H)ko#! zS$`b?I`gPzC8TH3Y0G0U0W0KnY%2}%WX`|NDFVl9j!*L(#JZs{nVtLa(})i&H6+v< z=n6nKGl0xsggY_EcGl7pPajIBK0cE^eCOkI2~|l8sOyO&V-9aWVfy4Vh{8{0RS;fB zkiQ4*6g4+r22Pm{>uzER2VVEZ(5G21sb_f&Lp(V(hwRDw$Xr-hfXiu#za>p8{ z6`=2RGQqYZP8ei5TQ5Dipj0dRI~U1v69}vUiF<$!yCiS{U9%SBYx*$qI>_L8bfunl z_l~588#~jk8uw%LjWCB8WV3Bg+pk>B|>7E^F{TfwZE zAm7V$tX}x+;=(i+K<}sx$6g}=7=p?hfW${WDqO#eZ>865;HbMSygkrw1ya1wDJa5tQiA8ZehfzWg=UFWhC8RY^^}w@vkg} zy=x*^39SAazN<=+?YLO$?$`(?n$#V~>_+%zQot3yevZf;dSo9Rf~tt-qFK6oz@tlc zYz;cPLx&Hf9WXX!5b_dhk99}}<060p93MG@>X7ivb~`UZFTqs28NL`9NeKJ`5HflM z#j3d(8XaVzBMVJjk(X6kRXw`pZvF<_nRZT2q{p6lB)$5>@24j{F1T};MG5wU!XRa& zp<0J`*4=N4#0}H__Pf}NZ@azq?ZRqI!{{_Uj&|Vi{`Bx;2T>n&A@mhCo0(v29z2u# zC)s^)IA~_44b^z6%$9UbLZ`~^XJ)iI14J^?W2pvdaaKKiAEXnXe2V_YsYoU;EFekZ z=yQ*zC!TvOO`@C8qHwo_N)NNZwRivS z*qrM{MdEmOybPFK^R1ykd!4*7Q?}kxk#6+e=n^&DdWDBWw8H0fccAX%$H9+-sNA?R zFLO{YuZx{S70^;QBlY+ZsvqTD1?;}ImjduGiiv&!I=fRWcm@3rg0DLLM4fAb>bp6i zfFOTn{d&4~{XE~pOf`nswS2G_DDS8NfoEQB$^+>rO3FS=6szA=N6;*1 zJv6`*xF(!(U2wOJ&>huBk175HDLa1s&av{BQBA>jJUFYtwvMZZ@P$?;wkzsVwG%C& zT|}@WchVp@wQ7gb$NyF(1iwf7$c>npddweP#*td`-cc2!D z1yF?}3d&R(r!Dx+H-+as$gEG?b5U6$-w9L3U*GP`mP*Mx#ulxXX})!8sSwG2jyFo{ z4cauL_|bYY)7oo-)kBd~lq>P2sdkNloX2!Gb^*{Vvl#6G7mxvhxe|n zfV7*3+-KzF&&#GuUh-)I>#k)7PQdk&a*>fPx5LLc);-7~F4^C9X!jiav;}T_} z=91zsj3w-t}?%Da)=ht%K4_R60Z%3CZxdfrZK;(9+u+Wn?mn)2%EXJ`}<9p zn{~!FbKeM}YkzPkAoCd-8%!_0@=W?(&(pMLI~*4ZtOxQAnFy>7#w@FIQNbg0x@W-! zVGM}TEwe_e5$xGc5TW=A`t`0~L3nfJYKyTL9-D$XJ~#=`K%d|!iyI6qh!(I^Wg(k{ z>w8k7msQ!_hjyfwfBZ`N33mwWoY;{DV7NUn$5`73IXmBVPEM?Nk)=JIQuRR3bV`S2 zr-OZiiGcu|H0$KXKf(af9T+gyRd!iPn2Fsx(o3(skml#-(;t8R7MdtXoxGb)uO0e8 zUwZQUkEb90>=hPms{B-Gi2=}Z|JqDH<06`|;tS3S-Ms+I3y)Y}PnWo4>6icdt90h{ zIs902W)NWqVoZf+wXs@y^~W!zqwId*YED#0EmBT}VdwH`Cb!`ur>>)UptM2K&LGM^ zIur&kF4P2lbH@@pAqaaTPq;BH!&;yG3C)wxtCvLTeeRxM7XcTkUb=WSz4hi>?2fn! z4$>Q3M8q<%2W(;}`T^Z1tQW10&PRa|%YvX~(3Wp{MJ`v&o%8_B`*D}FO{C!F?n>B7UhSz7<9r{@s>H8v0j$KbjAL~!!qv)Jw)6Ob9Xo2xzsOk_8d13&S$5x5oknUIG z5aWTb7lnJuXr7-@D9v)YCve@!Upob8Z_y@7rL64H{xWhFwVMp0MaNh~ms|my*XOZH zX;}r1BOmA93dD<>Vcj^Rd>{+To&U#0&Ag+%lI}X25!Jn~5sD(~+-$R(LnVe}F}BWg zOmR`Jz^s)!2^&2Fp?Wz0-14X3b<03^=~>|#bgaI~OXiEFD>X9>-Ka9gy^~MB@B};K zYw7>|-~aD)`s5dBb%{(?bJvg!7z0*TV-x2?kM2#c{p{uR`j3CW#={+WApBjP56un% z7W%q1q-F$!AzobH6FQBNZ7>*P$VV{Vpr){Pk^2p%rl+|ZVJ=k8^fNaepBO`B^HAXA z=0Z1!J04buET7d)sRXR+i+di?0o^zK_9q<{bIpVJ$EcsDK0 za6T7#PIL{DL2>{y#?vFU^x99ppMLW5SJR${_AyQ+EJ%htMK(@)!goxZ7q)cU9+Ru7 za8dI!zsRCj5GiC-y=?64p=?(A=vyG(60#w;%u166V{v1aFso^X^06J8j|J}1TxLEH(-7)i zXsyV}hn&SJgT>dZ&(J95y5fn6bt8~=(JSsqwfzuYO(m9x^|cQi4d+6`)b%MACa2Rw z`}c85hr2i;uNhWUDkFE8jrI=Du4Ri9aKjwtMq*~c#nzydWAiz*6@eo22jjcrn}YQ( z`^BsJX@zTd?S6mt6tKKvfV+jHZx=56$MC_*Kux2?D`3c9!6>Z`F{E%XfMs2##bc;k zf8cL%H$%nD6g~qhbf~$?PKQ`W=rMKmdb)W2g2@Ea%w4AfYXl=Otf7&9?qT3W1T4RY z%i3I!SY##Mi*)P&cJ3KVuhSVHef|j+7xr*uk6op81|tke)Qy+1bHM{H2WJeyeB{ac z?32U9+a91er#{l!`vWbWB)^n|#HO!?paB3#z@anZ6?RuJom|r@u^p`u8`} zxi39-$|^(jI#s7e#7RzYy!!K(ICXLmwN(S0j70FH3MCW4n77PJG999G&t91h<})zL z@6g?tBajrh25pEdKtWiDL7djhm>P52Y0PV`W4+Ns<4Yh1OO~-}KTdu2dHUV2-bnMa z*8LPI+T(o@epfiB2{QNPOSw*%S=)d+j9KZPIOuI=1wI^W&+)}762 z{=HHp8tYAgisfO+){w<_9a1d2D#2|`v%zklhUDGOKFVB*s(9JrnPGm40O(cbsh>wDAMJ(`QtX%#V)Q@0!2!LeiCs*I z%iUr{ok(!OV1vfOhq{*Y5R(|8fd@I^t{dH*vB_b3(HE=kb82gqcqIk^z8DC<+HY+C zNE~4ZAu@_UuMb@MppQNxFDuUZFL}kGYq}KZQsBX+Ks6?9WrD_DjVU`jIzz}ic;uS| zQ{7P+m$l;aRWTjMt&Ydn+>8@$1q7$5*5+AQxSq!QuBD;+=jq`7^XcG23u$y1K~9Y^ zqIi*p+fQN)51*>RvsO8ARZfU?q9nq#^*6Q5V_U)L|Z{|#H?6R#n_VjMWUN#`8!bHc|os6*3*b0V201a$66?@-a z=+`%4VS2ipHlN#POx}op9VAX}M;ll}(|?M;t9ekhG_uLJw~5KNN`63W72 z;xevPWl@MiNLu0ZW)0TmaB`e}*S^)EXi< zr$-)nBu)p_Y^ykXqk?yl*48-%!HSWKPOj-mV`l}8UbKIJBdZylNB}<;dR7KF8G~SH zg=vA}T15Po&$Swx5{kpJFT5dem{nJMva=D(Ty{patSrFx^Abl(at*3&ySn*#2jWQQf-N1WnZ-glgG zH^Nszfh~2qTR@}*hbBvz=Z;0zDC} zV*oREn|k?xFc!ke-iOgg z_|oeyrpbpUQ*GE&0LJw~7}0g0A|5ioj7@M4znl3Wov>66rn}LE7eD47KLzWM7fq3DRc=s7Mr%Fyr?s_}sturfwC;NyTG z(^sN6pil+U!t7%D^u*`s%{Sgo(^Io_W_ekHzCX+$M;8yG9_Xk4_@gwjYaH-07Y2b& z>{!uuu#w9)rS?Svm0u-w8P+-%EQG3}_Z!6EjGsA9M?S$Bomja=@i>Iw1$HPY^ zD^eQ+mB8KiHc_A|edwKc;MsdIp<}S?N%5gU)cvu@Fu&Z-MDL5#b9J6wO`{CP$EhP6 z>2>nK1Z5eXta=Ym1%$C8Sa$IfAK|R8eNk)rq#ym0yo~~P;lSQ98$)OR3=2Q34!;XW z&F@@mTJc)Gxq`;@A6TZ*Zh%i+H4S%TiBt41e5?Dsb?QR=GLNoC0c|+8!HfP?cwN(_ zK$ij!CI#}C8qpryKz`sxAr%$n_Md-q&O!`ebI+X~;mSC_@PP2lJchBOlhzu7fH+CT zSh<%mdJXjpb)J!)bDUDRn05?YPkSfl)BZiHX~*a?!UVVy<5@Xebcw`^-J8KKNl(66 zlG`G|3m3e@Xj_E--40f0D;2(p^v&b7`;FQChJU*{Mr#QaY#WwHYh+-l=#;(v_7o#0 z5TfZiPiAK`8ws4dJ8yQOp*hDj677vCIPl>U3?JkHxbW~TTnX$Gb2S6%C-97e;WYVO zY`PFf02y%|I!sdz72~81udm#qIJu(WhP3l|&0NL97}AkgFFb5$te&3v{?W9=0;+0_ zj=lRS@3q*Cy=(8z^!*<_m!5m&DO4#xl!k{9`a(k&aVUYHNgo?9WAFz_rT=-<0-t9w z;lh`f)0GQT=_;y-uCkzTjb~ zee}V}G<|(0Xs{O*#m~O;`5WD`IZzx!aKJopKj zO_hk34qJ&`08n=C+cB^Bfll;Sz9nGT`OYOwc*qc56>)R@v@H-HT6v1kur5ki2m>rA zjH1$L#~OD`^h4`9r$%~*IWWM;n?;40sRb@OKaqa*Z@*%J2$fl=1%tqWZ%)b7+8#eG z3e29}d(-Y+d*gl*w>K%9vflKu=#H4DYF6fz{9!lRDzY?HJJlMT&gR6%FbfTfY| zTT0hZhjeviF%}EOU2=uYEn=D<0Ptio3QdPijLsE~5x%mz7*I|mSrvk2w0)a@4$lc~ z>h86x(=0ZeB>;GHQs%&c{Snr7VYpaSsLIk44M3opmIQCar}+533C1@@`i{BW)xtuH zMuo(g!ZwOFmsYS%zYMwc(3^tjI(}m*P_@%0e%fvLa=F}a^8i10cm|V^=HRyJEMOxn z9K*PMFjeT*{id<;*Rrs--ZClWZV@Gz_R-tFMR zLQHw=5P;+Aa&3TnARa$}aK|et^F>1L$aHYz3cCUmr%S==8AxU3BQ_P{;ujCE8%z#k zhzcb$Sv?>SM%v`R42R(vDvu_IcXFCyqFP~vf~<6SF_W~+c;;@Zl{9|*x3KX`1GR&= zc5vj2lO8pWa-2SWHXZx>@pR(&sl-WgLqR_P0G42B_kl@{VIECSK7BOJbHsdwL5*u4 z@Zcz)AYe`)ew*pyztKp+JfPa>vlY9TLH7>0(vUI?xEXhaSC*D}>|r_f-nIj)6Kduxo14R#<9`yozfY24CeCSAxN1Ax?4f98|GU~f%b}5+EizD+s7l# zc0iD3o3DZkX>$lSreUr3ud2AY`i89&LWCh$=Chp%=+|_|t82E80+viEo#1W?t-=ky z4nyr>>rY?LGEYCkF8C5kj)r#(&Gj9$c-JdQ6x)gYzD4>!PI2i&D_hUM8 z=7MB?7vvQ1EL(dS^~wTbylE#He4xDL1wJiaU|i%Dp~fvvBEwT^y)*F7^IXV$30=_V z(&XU9v}b&V(+a3>7(y4bV`Z39oK`ShJ89xYa)Cv`#`o4tdq^?-+RRoGZ%6iPL-Y<4 zZuSQh6;VUrc84IgJ9vP*7hzRTzGzqK`!Bd{P3D0!A*DmW?&o~695QoAWB2)x!!pb)BsNC=5 zWtOhdiLcfAUPN0BK)7{(%K0$ z3OxPX(e$(b^m-cGHOxr@;s)^EnRzx6UcWqz+UV=)*ayeb@sCcV&rz*( z<>C~((&f@{Tz`V(5j$g2k*7%|Kak?Cvs-;}iE|Nii|OjcE6lZXK8Dzk=z_o#Pd$c) zg(uT~PBQF3rPC1VL2Ia&_B8Dpb)t+;YpAH+wQnN5_R}Av{=p&SG&O|epQd_$Ej{)8 z(e$IAzs$V~hgei#;~)5~A??mP&9RWzEHg4~k#Ay|k=d?az?M@R0h00vS(60w!5)hX zcse$BLBP_imUh@@lzYR1p?hP+wS{}Z-kTeEfY}d>8u7gIV3>`PuQn9BBElrypz#q!I3ZsLxFi!-_(b=z5=%h>`#s#?*LLkF-?-6Vu9-OwKE~x@&44PfAAe+Dc^I! zBWgPIb^%m(?Jc;s#;Z!Li3JhA)8?)h7NStwNEd|=l?4(PD!2J}ej1=WojzULgHM5W zCfWR^rRHxMEsa}$5EbGRnUUQ__HAcE9ZmsCrb>939{+Li zjo*b8@tyyKr8}LjM>zrHk-Oa9a(HpejL@XPfPouJ$3!!()N6k7RSd5D+u6C7zn6T~ z5ME36VfICl|X90mZ}NlYHu7pq!*=_ zLX}X-v*|}iWc)UhDMeg*yErD>=9fP+{>521k)`550!{{V(X@xPQVqIeu&R;KP$`tmv!$uIFVlX{&R83xCwadQ&V=_L{WuH_vgDy zfsJ*pS()yfB!IGw19TP_dgfUSM$pBg!o(m8++33O{^zxH`79?GI7&R$7*!Q5 zAt|(&pj1P%U}~ixO(_>{?EEn(kNR4(tK2My86l+KtMEb>*Ey98PbP4OkU+SZck+QD zZj<2_h}kE58y+M>y;2pANsdRmT~N^2MMRa&)-)i<@DrYWly^N~I=~yL0sy{U)1^R{ z0uMF?`kBoSA{wbHObqdZ3!C{%RiaBU2`_fchBoimK6ndnSpa<0adueZof*ae>J4hZ z>_NBlsC*I?EA_RhG+4WU>Z8+X_vpoRaL-ga{Lo?=LiI;JL8TzX6J%}{0S|akk9Wbq znCwvJFyC;m<~$NSV@&jQ;7gq?-PG5S{5K)ZlK66_A~cZC8A?nw>Y@vfc?)bBDk?4a{=ey%;{MnMB_TUin*(gyA9ou zZ(CGwt7?-_Yui3%aqP0$pjKq6;lth0$Oa-1`As#hxjR5xj^(L+Q_J#z_=+q*#-M2v zkZ6(bke3ilxyTCYW+wM}59AAJc&tBtc@}kV2Omx^zselF-^H3m!n1Y5tZ;uKrNpm&u2e9pWgZNd!hRH3ik~z%`q`n1q?KZ3EWAWwMqah;`+KF&zNy1 zA^Dq|UP_;SayFg%=!^7+-@KWgL5}eHPhL$gzxE;v!)k;;Z4P|dKNmq(twYqMepFWf zfV&(Iqjve%zxj2(g!2IgqCu;=Ta#WH(U?Kkr_LR^Y0Z3A=~wHqs#k&r#nZ z@!&`Dq`2&wcTfrcSOh0Ot-1+9(O#(Kd|IZ0iKi{Zsn=Z~l~JIkZQ|<6>z#8q+N4hJzka zPg45*Z~usjtv@vbOF!d!m8f)%woiE0HbO2WP()>EXegzh{o?iXv;Xiq8&Dq)zkW_H z3^fKr)?}O3ScRgejd}*Xq)qsdP`qKvl(9ILL>HfmV=1a{BbU4U1*09 zxHLmNI3LyQ_3J>`qUxFljRLuYxjEMD{<(qMP;4>$+rTSEP)a|-z>J$MO70U(nr2N0 zBG=Q~PArt$D8=UKVddS6YCb#hMf%;p{V9EV{PVQ1i13CQ%@`)`MTj&_h$zo+Deuvz zA7?O=h4t!=4klt&WGwc=Jd4~~-wRU=Hd1w*c`#)zZ1T}~-OLb5jzQ=( z^Tt-+h z(W(H<4$t@e?0h=$=_z(C98VX|p+c(SkdafkTpzm%9(npuI{NHm?2_1(=Adt1KS$qc z{Xhf2av$IIP~nkrBXtwuB_1~zy@L+m*~D$rkP-DpAL&w|jFE34V($2%jJD51^Ku|wH+zEJ* zyOx|_c&W2|%@lHiE{?}KFYfd3EM1FlVOR3;a7U0fNTpIO(NbY%(W#X@@Gg<;2ki*L zPK3;;aSK+--YA8Fsx&biCKd_^$G<|nvIDCC6a!BuluK6S(^brf-b`0p!KvVqLv(+; z6zEdm0i!@n>f_{<97{!`F1Ti)%L2SYp-JHb@+nzP=qggO=B{R~JPmk)F%2TWbn+Q%@>anXGu_5Fr_pU~bGux(v`N5wA1YhP~b zE^3fmI5>IiGoDY=8SdX*Nne^2DQQpr4C-YvdOh zfzjd)1x|smOv2w1T;SIiG95fUNp(svULK@&b zOhq20EO+(}rM->uw6}jE4Xra8=bi;bR&mm7s3Xd=+FXX_WUCQR@c#N+=MG_ZBJHwu=cSr9Bvj zFUpiBi#lYYIdJ^)lS>>|zC-t!YygI&%Ae@)IQC{(89*QULcL7CGgodft`uouJHEadlv0 znyq|=ZzK>tdJ@7U+X89RIY03&V7rLyC^Hs*w!+XVIPogZSVG5tID)e$HYy`tF0|wo z(Vw@+VQ~>>{Rl zR(Gs1@nA$AXBpH9UY|`9{TC39T}$KrjdXUPkuFcor`r4y69wX}4x^IKyZI2zGs{{o zi^deY6JqT#F|nVtZt*yntI6Xqrky#M_NtGd?~5uySdUppyv&?)y%LU~{2`$Hon?_& zw6)(b4-MV$D|bMwf`_o2XUHp6bYxhKDQEl*pFwBr^zAgdTP^J+rI8CMfl6nvuB}Uf zE(IP03WQ|IMb&DG3C~arV^8D-^LYWb%1H1^_yxQrR7b!kvv4S@Jo0p_Cw%2U^_~Tu z8FsE-g?FAw!z?I_k4&e@@rAT!7xyV}GI^j5cLJ7wnHv#NnEvq@uJMt_lA-0h;&*pT zT6xvYc-!5=+}3#HZ_6Cj%xu-PWm@`v)kG-co~@$W4YxH4SOuEZpskEcZ2Nf2+dKMt z8RK{w{OlKBrgz?aKYjS_-_s>@_Ae~YhdLG)7Bmf1+9=E$LLmP6$+K}%^NAN8Vbj`Z zY?@#a8ELvJKUyi#;-pPAUd?T{4X=Q@@uy&&+jh*~O0^6xEbsa7BFBUMh`C8YPks{aYDxiT^!dgeU7jjG~y!ZnPD|nE;A_4Ks`3Vbt zj)DDU%!-Po>sO}J^i}S{JbRXfj`i3%{?v<)r2~&~?tigE$oqp}1ic@|OjWdyM6tle+deCvJp8{&%9*wb z=MhunEE_lea07<@qaBqm$HVW}{N z=XcWiFE4N^1-Y3xR2Tpzmmlw{gtCu?*d9oWO;2N48qvpB_0Beatq{f~yqg3XEd2Dd zV7SQK#uE*oi~P!Y2D1zzM>5VoDoiZBjsy(QUcs>5&WKZ{W)7KY2q4N{aFJ&7E4wT% zE~QJT>v9n@Hn{@Bb`XnM1Sy+IZo88jE6ytM=WPKzW3f+AEY<`@D_sO(^=MCs*v26Z-wK-I|tJ}Nz_aOX zkU&Pf88~Ue$sggwzxfKU@`|_2u5|PYbA)4E^3T&2+NDn)D-7bKOE!iTWME6EoQ@>c z#|yC+XP45Kr_aT4$jcY6<{r*)746(vvhVO@df|u9rQHX2(Lo>#;{?MBiwkQU32{-w z3rAf9a&$yi+Ah%ek;)fx(e} z3mX^T6{*=T#Pkt7nPO6(#BW(XhfA}+h)P&$fptf~IbMVC4iaPcl(0y|#f#D>F2hUb zfVT|aR4m|GHoFj+tiru~c{9+6C)gWz1!dkUTpL53^qdyQq>H!CUDKsNmjVwK1w5I% z#yDL*sBj?`6KMbP2>)O&mB}CS2ehH224hMzn8I`5lc>q)b)nV`Mm#QBHdu)5uT7T^*YF2!&Mh48iBE7&(w1L3 zG`w2_2q1hjO=j_1!!F9l%-qgM>kRmIA-n$HDhgPSd~9cKoIr0s^YIT*r>k1s&rY6p zM?uWLX<#wX=wXB4(wutuSJKS2`E=pz6;6oP)8Ht(u=hL_b66zGv_{7d2>Ps7wq3>p zVR+>f6!Fu?FD2rlWA}e)j6ogijxTB)$Jwb)yT?!)|l7V)Rq9OoCF`H!Qe5{0ZA7YbPT-t z+ICarDW_se_AZuVm1pqm;Q_-)I+u~3#%LMIkrx;8INkBJ#=g>9Cxpq3YsgsoxUe~; z5#E!v;k2tU#>L3oUciFF3K}C8=9kkaAAOpB^UHrv=e{_Xme`o&SlUvJs>OGt70_Eh z!Vkks%nXx>sI7_=Zk4-vGLI74cjc%0vf+uLbd>u&9((4owBX4jH)s-U33Y`_tTSloEM-F8OzoaJ< zSoC!cp?`3Wqn~HaoJ${m_#qvM`(FuX+7d^u2hw#^UcKkjcTY0&h)&=7z3l^*)cx^RbP%Qm5_G_weTZOl(YdBXJ z7!W*tl@WNIi=Fq3ETPV0FwO2+PM0sAhj*dcZwbX8%Z;?SItbhy94+QSr)uB&AaMua zQPiTSNWg-hF2l3(VngOwrr$@pL!@JmAhzx<@&eVE`vi8I{RlKqE@$~1ipu8BB;9+7 z&!oXmjr|LMx%PSybU0!qfD_!XXRBc&T=cho5+-oVzy`oyfueYG9i2B*FwyFoE(N+2 zc+e;iJjRJU3}6=Cl*LxGf17;p0oy!$Nx>Fj9FnQ7tp+cxp?kZ=$%MW>R2}uq5^5U$ zHv@~ELp9R%G%~orLc&@)bg+@eMh4PAjX2yQ)64i(ftwp``VbJPHbYh;+lmvigvysh z%a8M^EjV{@966+-xw%5|)_$+m{`PM%hzkG}t!;WT{?BDz?QEukn40x{}CLkZG;FNT=27io? z%9C8ir2)NZWORg63wc!37rXZfE`L>{Lug)LTLuDW)?w4a)Li=i{@-7xzp~hK2JH^9 zsDOK3WL2J^>?6}xn#WkyGH~HS8)@nqbcJvZbBin?CjF3J80*~mkcInsVb(LP z%5Ok18^94IV1D`ex%6+~cZ#v@FaFcd(hDy>jhKgxpI$Zwvf^WjDVFHr-i~^s5t|sj ze?vSnuLoD^VQjhzRK=W_1W-<~ohIsy(4$jvjz${hhYZE3I%G-VMxrL|U6^jT(I`)f zO!MoPr;UAFaCe()Y;`38cls~lW?$qOc!=o3EYPDcEKN3s(hi;)_h}-t$G4u|fA8H; zEp_4}?~u&>9pH(LVKL8fe%yJXT@xM!ll&GRokLSdR7mN@!=}}Euvwv8@Nwg&8a|3? z)EnugAAB!8|I)K*&!IhOt-(BpjZ?Erb7^XJnzW6_1_5(OnK)N6Z;l9*v}8I_zpJ8> zPqZ3ENk!~39b1@JIF}5SpumyeRM{-95zYEyEH#^0$4xxyjdflG_;1$lh*_1j-?{Ml zD0t{7(!#tyX5;++{-kn$pl<8Jf`qsgF)Z-yFj1U0)SQi#;%-vv1R9m>7ATiN$L&(O z6-{mz_d)S5v^B=y^J9I zqxVmyU;fMQLWR!?tMUrCWzCr}0KjxuRCxBKr_xXVX$&APqTi6*ee9Tn< zlpGPgj3tZ}Y3Xf;O!}cOF(zO#X55h;^>V~K?qCQwFmEX(EaMR;=tf}Osw8r{ICzQ8 z$zvzd-_ggn%4JSQinvAw-HF|!>CvYS(OEo$Zo>gOsKAy98@ez(LS~j2$VfAHm)e1g z_ogO1{2>fU`2<}Y3`B(do3RWBe%jq~+Qp~yT~f_-7LQOFMZGz7;?%RY^4OFeBz-1sLuh7U7`Hk;*Yc<{Q@8m&Mpl{Tx1l<{d+mjoag594M} z9mh#t(lWU<$IoyVlZ~@gFh=nTQKu;J$E$0)6xfsk_M_FTD(cFnfxh*QG?*~F8GCwK zp*(A-JG#UUbnorU4L-vD~#&!*r0`uC`c{vcgEe-S}6+*pQ8dax937wf3Lw@A7M*7o(uof~ zhS zhQp3eJd)Is$|wAocef!#Qs2$h)+_C78quD3+BE+_HzufF6DN zNP7A8@1^HncqT4v*3;yDJo*%i2XGhH?2mgJFJ@q|QN9{#*6qn#-Sa#N~K4T-87mIpof%#kHr%Elb z%s=ASa&`QsEyi(t1)eSMws>uhRTiTl+Cpsh87DD7-a+Tw)B=}zpNbB5bxB&m<^peE z^VW=^5AccS4yXMG_lC|-cMnAUBwvwA@fiACvj)EfV>Py}oJqY)kefe2_htK^%py_^vLoaBX4A zPk2Xp@|`bW#y{f1o8qlbRQKl-o(W{FQDc`CB{HW2F`J7D3Ic?ryBM*T3HWIyD`SM&0Z*Qw{^()&35-~BhEeKC)5yCZC8~Jv9K8Yp~YLIn=HcJi*?4H z{Ro5VJ@B?(PA1f6(8+$4h1Tn+$e87#(nSOrD`{egdldSY(jYrq*Vwkn!Gz#Fj&FmX z0MjnE8pUM{GLyy8(!vwd8iR*5_|0~gV@?VDauVI&`#^y*=D81`Zvbg|T1TR8l{$4p zC>;oNvqFzLxM`$&a*VnQPU6_=sj)OGwPO;KD19{u297y~KAI9&FT*?jbkynQi0tQh z!cAcc_8nJ5G=y|)SVP6g(xUT4!qBHHxZ^W^I)=Sk@LND_%i;oxU!*0EV}U$Ds|3mc zfUs5MNRJS$cZTh67_)LqQz08jmG2Tkd!}D;X}*Qq3=S277R9$c(fpkuiD%2`*0NW? z!l4Pc75Ij)Rv0gujf;t)iL!(!pX#kpIy5(6pPAv3+Rx9hsK8wp^WHBA&?t{wW-(qR zAF;@4dKR{U9J=|kZ#F=+$gzgi?LE*FYcSgcrDq6~pQ%cZ!BbIZ6n zRs@FN+D&AEA0y)zV^aK8_dIoVI-NapCY`^qFXRiJo=_HE!j?K=yueKun7pt;eGXfO zYvP$FWY_T8#vk*QHyh^m;IAoJ4$Z&Fd#+1mVLjvv6&`)$Syf+d^D&MD$+8Etj7t@t z%m#FK+Sj=Rd9VTfX?w<+pPNhXzVmK6cI+4@oG)j{lAwAN^@RHHidI$9BAN}w)Lup= zS-i|s7+$)0N_=`aVA|` zSx8G|Q!0>%hRJj(S8sK=RKY}Fu*n3`(q^DL?uPUUyehn#`7&O$sVc9gL2>Hu z0P`y0YE*x;j2zIrTs60ztL8Wnu==^trQP?=zV8%P!S$=`1Gk!^J6>4G#!BOxP|}5ihWDW+DY3Er8APDszuulkM`Id9$U0T{cYCD9O+(RmZVE1DE4pidn*;S zYhghKmdnw)<4}c$P6aZYdi!hKPE~WY=47NbG_#f<>D<eF<6igPX=V0pCtz9_=uXXgj6jq$BJwm9y-qtTPb zcGlA>YCjgytvfw)EnS_u!n49ej>!cl*Ot+5yuvAo*l85A3Dbbz`M{s^T{KJ#tRGQZ z^6*}~NUj=?i%`4CoUlD))<>Sh1paNo&A)$H#fcnTc5V|IJ!9qA(tCmVlh&-Lb7oQBaTt7cAkt;831SA3y+bjSayZG$ah1SMX>EK8^M%x zwgYw-2IWTmx>J+;r7gzV*vS#CJ;gG{+7u50mcDRfY9U_R1op;ocWTdYP1mQ?KI^>g zPP=a?U|-9aA@F9L=#JvtC+9eM0U@`Im^;!DtDa?paZcK^DlpsJB|=9iw*EW9<+u^q-hl@}i6g4Z54`-xMG00fH)6?ZvjfuFc#EOX1n z8u`iY+9HebQGVJ>%WogUZ!keC-Jwsrx8H41N|8CHixCQbdWVTgaWZ7HR@mRG=;tz$86q8fR20IYePM0@4?;aQ%>$LEO=_4P$EOQ z7)_zG{7$HGGC^j@Q;!R_*%jZ<=9WqBS>*0P)gq4}M;RR+XQ5XW(v%yUI%ntDl|DVq zCCpdTm+1Gua(+6^Of9Bm>cJAa|5aH8_`uD?bNkCw&^0^}VAKZtP`Nym#(Dau-T8oXv5#F@;=HZgl^;U96I~HSL+S z96k0U6SCp9j-yo6rZ{ykaXJdB6o_@91P4?O+vadvj5ZG-9MijNBi>N~sXTBF^Gbgj zCYZ<_g0`aM333agLYJ0H>#KuSE$8@Zk5|4o%WKa5))6hRHYB&HwWQachIG=j{Bq;e zULHPTeA8PEQjFn5AUe5b(}Wo^X=tEsr^c4ZR=VCsDf*6W}OW*N8x zPsU~!6%E|Mf{aE843u=w3jhSP@)9WO2<|y?g#O|Y8RM-HNxg$%fdO`*hCV~W#gQ=U z8Ll`=(Ph1-f2Lv)e-RX>U6b~L({XO0lZ4n$4l)JKRBXvQd4|>)) zJ~ubbu|w#0{G-p)kN<9hJxHRyN+bQ1*`{x1+G%vNFgJ6*72KP}xs`iXJ5>6FLT_WxpKM4hhSPyOeA}|+ zyJWNUNx6K8&P3cg;(kD9-s@?OhaLT>?;9Dbp$dfK#(TMJj9Zfz(WN`L$mD~?;`w-ZKxI;IvmYH80tv*Q+)J?{+L&amtsW>@_=KWM5mugKl#Fxb<9YInPlas}6DN;I}s<#j{m>gATW@ z3U`HD8Q;4(Wn&uFp7t7)n{~SjLkg_17#OJskhVyjVebmT2Q*TXdq5J$kM@0`5`i&g z4WifB*;ucwq{8X|p|SrX2+=2(IDfwkt`k#M$K$CZvZ*JHziqZ8GMu*k<; z)ZJ}yKcCY*;^jFtmgb)TmHK6x!RIWC5PFD5j<26zV%kf=t#jwh-h;c+Km7Ag()DZCxxD!pLjS9Q zYaml4yJjJ4oc|*`k&Qm`?7{Tx_nu5oa1rnD4(_jEXL^IX4eB*C{xi3Cp{I{duZIb` za+DS3?W@Qwyfpdx<(YKmv-9cWznw_$|MkOk?a~aYmVs-gQK^&1x>%U6XOhwnu#oW3 zqvPp^KYby+^y>5E&G`%U3)*{Gb6nn>sYe>}ZKxUe^M>ZFoRhuqs;I5yJ{Sex%2q;O zKXWoE2*qg|U=ji#+D}vi(ywq(zOV$Xt?nqWrfpG{)-A^6eQbvGV)I;C5Lit#BbtrBCKA zrPE8-)74(!5q6CE$S8b7iJo^ttdZ}SJ3%YuLCVnjpodjilEe%->UHKyxw?XG^AG!71Es@^hzs$DM}1NwRnBTzFE6xEUU@lshz=5Dy61wSelh zh3f_<%WJ#a{-$-HpmdXWJCUMtZvvz}O#AmuK;GbNrHbkW4Q>~IGymu~D!kgi+Y{Ot zF9+YOQRG;qGHi_34s)~b2oKn3bOb2rw8Qbc`BaTF>@bls;IY|ja%Ur>g~7>CdH0}C zu9wAt#B+_#>iFMJrCnOal|WkT25FUKp5VW0qbn{&t=B01nx!Xr_(WU;q=6ge1?v6%077l zLfjb0fIl6wox_E5mqYL3g>#o;FknAuvOemg-7!9rUVH5a>Ck~gEOay~b=bfsef?u0 z#ZHxos;^+Y3XM$1JORW9CZ1QFE83V@`Mn)Vb0DdJ%^1)he|H=>z%_psM_g8Iq@bnv zSx#7Sa`D7V`J%2gsSXh8&EmpJx_Itd`sCR0bm78962+a)Esjh&Kol3A!;c+GM;SN` z4ENKyfrZiW3d4suRYBved(%IM>i#|e6tEn>DaP8qRZ~^_4Z(9}a!YH2MPPkwAKA1r z9bfqCRa_mg-_P0{e67OdZeI7R%LW+boQxW2S#8g{Jf7A4D(w5pD+?baKsN zI6Tsce18LB0M8PFwb3EIStwngPgr7+!ONf#4^YJHaAH8OUEV0qZuUbrqV!Fmim*UI1TLpk?Ux{H3R@Q!jQq=#+b(K zL)`|**XSK%s%8e{N9M<|B=lk|#wN^=FS*Fji(sS9#rBOl_a^isen7c&Z=7E6J})=m z5SM~cu$D<_EdzL1aw0J{Zl-r5$ulW(>_V2d;Kix?{)ST^&w;iX$qjf2dY3t@AmqM`3xS)?sW_=3NWHN5DV|>8$F6LM2tM^YYMh&rOx`LWUFW?n~ zb4KT0&Zqx>_TIBevLj2=^URFYqO?F&p$b5uphW`>pu5p(dv`d3OXI9@%t$lx1Ahg7 z2mb~?%oox)+~v%0cf^kDo}N|!8V#)qrDbZ#_j!-|jT?~>k&#(hA`9i2H?Cjzs0`sL4lqk;Zg0#drkLmrHVSq>cCm(J)|n{zKbolc%PnvR_~ zlnx!)r|}cXg-$U*^LpniCjux+2DZUNY@`D@7?2;k8V(%TuYudg(zDN>OCNvmMf&J> zpQTSe{#@WnIWnYi2*SXE@hRWA?kFFF&?oi~bs zF)$xvrPvuuTGX*sKl|sOrSsZp;#Hi;LynJPz&H!vjFs6+@lZnc>;BCj{oY&YkH}f!i{u!@w!j8AdQHc$F78k>?fU`kkOm*F2;3`E&fQaPx2tU zfsJe@P*=l3R1A207mJwfKkY(%?3iXFLw z7TgOd#L;e?oOZ_!-z6EnaCHc_=be!GhsgAs-I3(>jv(I@s=6sK)(e*S5|(!xa6Moy z83m%Ko)Fp=z%4(juqxrEk&<3BL0i+o-|db~Z`;6WT3S)HK{Y?C^eaJ4o(M}V9_;mp z#s@4SF8)xTu`*mrM~pTsR1`%jRm_9}t11T6__=*!A-(_JXX&5+{^#lEKYK@8wyO6mIz&f! zL2c7eDauQ6>P<}{HVN>EjPSNx%5{ueDuqj-=Svzrryy zr3;&%cp|;<>vv~~#ii@MH zhO`4^P^)RZdQI-^g9t)MHz8z%hem8v8VrUBk7)={zDs4{Dv4GUywY>~#%%iKFMgB0 z(#2Kt^O}I7vFn7IK(7oK(NS4XKl@ZV`^;ImXZ+x7H^t3z(eC-^dv9z9?1)2z=x!RS3&y<+XL+*ue-f)5z+u`JXJhu!>b(z;!&y~5H^+b200+?Ez+YM~T?)}gJ z^8WeNjX$N?uwzmk5ME%h#?5U#efA*F%|M4$s2sMmsc4R5r>tx1h)P^JaF>!(a22S% z_FfZ%Jf~@1=tt!hp3r8G_8;xVXft}JEi6(d4{3)26Axyb7|Fe0G5DYX6DhoCv$=FP zSps67huy%VZ$dh|oH>D8vy)^mD!YT|3Ylx`=gDgL4OFCezpf9e*=JTLS#0+^YAb_}n*^2c^4;zL3#N zfmSf>79y$BU$+<7PHx4o)4Ra8zk$V#hxS#pv1G}n{aY*+XlpuQ0^q>`TTAf#E^f{z zRTkjbRYS()iV*$kw!Yp&T72@%OJ~!<;)0GnUQECG*}G|Rc3F!}l!s~#HFTuS@zclC zx#yosFKL&;i?6<*!G7hn8c3X00KJi`ELc&WTtxd@x}1Sx%A+5B(?;R=q(eszro%^% zrKg`ho6bCWUPlfeNn_Ka>B}#^OtaVLJ)n#rjcO;vsV9%6Z~x%s^yYV7Px}r|n{MQ< zlyh)m$F120YUTc}^05zO#2&{ZN1v*a8U~UwPncxm&yxtWQ}Gh>m?+=^<52->c%M?K zlBjx8>C{al-qFBhg#Un5ICeHLxxTczlx{E0r7`iCT?3;NjdXnaXqr5{FYYPP-4+`< zf@+XS85K;%lgKYm`02f-2}4e`7#bMW-J~1ovyZ<{zkB}!O)PvZ`iMwCGNjLWsyF!` z4kjcW&}7pKuRiU|njh1S7#$Fi76(_-O+A+ubtKmO?X;+E)0|>4q%>J3Kqxe^a8@+1 zTLmZafkkeNC&eTNd{R7OR`gYoy>?^jvZL&h4MmgX!^xIkp;>%W8-!xPSkn0Tn2+>2 zdFq5Fz(UqQL|RLHBo_EEm8Ce;-o5;4VBcWtLo>0^R6KW{n*|p_YeD5VJ}Ttx-<3yY zAuvK)GvQQo=+jz|s7#`Cq9AMzTc@dR-wY`i;cQkM9U)pAjW5$t$K7t{n}{K}U_z|5uy{P1|^+~+SiUXdZ5)22_Ai27e>P!F1T>_~A?0hVsr7oUESe)G$B()BAhVKmT32e)*>Tygiiz8^I%bhO~p_;IZlS!bHx;6RyVr=BOv zsd-$FqR&ZyRv41^q3)>@1f#na;;dOhq3Bd+gwVJS1G*t)t+h_7KM1kA##*wAEbcfa z1{d2*OB9ZjA6wcWnY329skwTkln~JIrf<_ZR4u>`tW~+&L0Iy*o9X4(9;Sa9kT2R( zyK9_^9%-q^=2lhrWY<9@!pQb!(|>w5hsCANTW}#W6-k{Ult0e2yXuCp=xmJJnPD|> zdmP1!cQ5)2zB>F~v9EIUgA%}wPz@hsS+fRHvz@oxB`1EwbtV(M4uo=#{LseK!yyX% zvLaTBO^~_&u$P&$!%ReuTngG`Z-?j}1$&q{(6KxqDCi~nn|aY*?t^{)!TI#lfBdKP z!EZm%MZdFYes;l^@S^<2)SsT59!oR(#?uQLbbjpzucTA^PNo&LA-Fu4<5-4OiEaW2Z|fw6u(z~J0mnWem{NtJFjVX!(mM@(PLN3iSZA~OsMIocj@ng-}RLgwI_#(Pm#wUF=G?#Xb9b& z)Ym?%OMOqCc}%+zo=t!HfBb3s?QcF#3z8qg9C>VC`u6u;PXFzH_yhIJ)qU6fjZ7fK zZUt~URgSb*9Z8D2nF>cYfSP+haX7!lSMEq)9L8ZXoh+D*2@qP_;)CFrBpCL@I7w!g zf(F)=EG1Wm0#TdA1jCAWO?%b+wVOrB@EQ@*n9fF8Qdzq(KbyvMB&wSC-aSB=QSDq9 z*TfB{M`++nNU1lONLclS+onPpsVLI=GWVg3YC>@#eevn1Y3^1&LJ2-74xw|J8j=|w zNd+zuwW=|@K@I3I0rlVf`+t)jf8sGsA`PTj9f7nwyq?Z4-AWf0ZffFV-orU^%4I|0 zB@RL>6Tiqv;kEf$WC{oo?y;dPS0T?1oPY2@zIa5kCcBs<^KJ$4nn@HE;i8jReCx>! zA(_-ph2xJMPtQDi&hN-Ku}V96Sfo0?FsCskaayoJCD@586T;^svn>wn*@=biD2+}W zsfVxZNR@=U`t~Q^U}$x>OL$;yF{R){7_6!~fR8nnqJ-0vLpx#$ox>aH@pGrr_jNP}SC%&v|X<^Yh06yoH`Jx81ZUB7ayRU1j=TY-pF*q)OGwA{k z8)1ZkRKuDf7Ikb&{_ZZZ>@Sqe~C~-@Fxx-o< z@|*N9?8fXm)l#HT7FBf7*p^dx2%RgHrcMR?4h>PFT5TMEw%_p!q?-|TaxrJ{sD!>m zP90SJk4&{_!Ilqapo`lB?5 z>4r=0Zo9Uk?*FUMst^{V9J1i7>$Ktqh)R{uE zVfWe(yK-4V;zc+j-fdlvGSFU&a@f>xEJ~p*#v`dPZ`8t+UW4bf@n95famudC1u&V9 zdyO{_6TS5^u~oY2Woawee*CMrk(o}W$Vex@hsI5%LCV7VM&+T|E>hyHarC0R>5o1e zi@bjQufIt@|A(Ka_kQ(`jvBtDKDqi-^s&_~17vxANteXx=#`sHIA|5JE?0kfV?LdE z<&>Hl42KF{1Hs^=U{VLi5hwcGwxPTiZ5f)Blj|wkF7$PLR*Hh}lHDe!4qvl}u2DS3 zOMKms)B<(03J0=R3{rR|A+LKfBx4#Ciw8-gXzcr_U&})%*iyPBapq|)3XPpg_Z4Eyr(qzf?+YueQgPs zSL4fljWp2FSBl+NcUZ;7-Y~H66&9?na1yshd&8jc`(gXc``7 zw^BpLAT6YKfAgO2-wE^EH3$%SRjZm>&cZAJM`8YF+*lM%?2E+HG2Y;n|+PCit2BRe{`Li~gyoM6F7 zU>LLvGKNnFBV4OG`pJ_y01%%hJBPH}$`cFnhlYcP_N5nJeohN#PiX=2l>HjYJI0V4 zR`}ryaz&0nvt>--bLu-p-gL5DHa|DnI?3xMAUWK1+3ghRU3A;C6AQb=6Ea@^VX#!+ z#hrJ4>XBA(b;q!ycx*t|u~cOLjzy*r(}Zy$*Jo2mQB$pg59uW#1m?7h-V-H4&63f*%=~9q~1vD30)kKp4W7FI5~2c%V>70g9K|(Q<1%#~{t#T+l_$ zU!=eP>wiigzW+(OeREz2#84_SKf($5lH9W|Jds}i-Yc5nNzq^+22w%O4X6TnK&h#;tT!tNDG#qQ>Ukv4bJG0$f{KOP_u8dHR3<>@R$2*2;$X zr$1(wO_orax-2WkE+OZFZ5kCo24eXO4Tena(585FfHaXOl)NISV#AtXvqyiW%~AT{|~8 zu=n#%&!=~P{k|@!<_-=*3rUd|(KD<{_n^)PeCgHawO#XoFo7|+&V@qGY=?~aqai8> zyXi+n3CjE}Wd^@T=0W8EGO)D)sz1oUg$&#+WBC$tm)%fS5tYkb=uFEsuJww=1*W7T z%c+pX$|_)sU!dE~-%4-Yy(l`8TcWGt?+6u+9+>Lk|6qBMqLR8AW#p$m@!^fJ9zqUK zvi8AOnoZx-B1KQ3%d$_l0so+zlG?543|>(>dYi1#U2Y0TdWX0tzCpgC$~0BqC9gBx z_k@^Thy?o4U45ACs-;pcLc=v9pv_Zcz3JMgtroUUb=_Vtxou@#TwJ0o7T6-O{?iZ3_g-=0;IEGk#Ef)B#Y0!?zg;fm# zf2iY#r;bder(Qi{yfiTAn+wgEHYd4 z4m}_BIeLfz;8M4H!DJ^r5W=spg}>!}N384^*l7}-!@Ad!@ez)QA~wcZ7-wQ(EK5Cy zbyW4RPJ7@;YsOn>j{{lAk4f^_)we2ME$dk{*}@_Iv0zB+W`#?*HLk@@kDD6{>8i@z zg{7r5wLX?6bXj$cz|2ru99b2;%jwGcZ2CfXDtxgz zn-(?h*3iVl2rEiK;)FIQRY-e7ay2eGM)YQ4fx%}cq1MD7CZq&nnboh1*ZL5z7r(9BDhC>@Ult^aVkCE=!pdwVgaKp$b=m?fB8FL5Wfs-NPq*T zeAeG_41rw4U|60J`kM>@XDG`YaXoOGok9XVMY%exUgOu+=+DRsS{~p zWKUT5 zC&dU{EoAUf%32PFyoEGo8AM)@fBjXmh>SWYezqBJE3NVaSB7Mn})8bC(!F zY#2K4^(rG=cjLE8VOM04VtKo~bcb)sO2ELK2gMZH3QaCoA};b!bQVVULvWwTDslCL za@#O{&|A{nFYXGy&-C?+7dUWNILsf=z!b=#OJF&XrFWsYoV%0bvf=1T){oO7Wg3vFFdin68A=0 zVNwA&b;sxj^2d=$YAQrGQT@r)<&AXt;tdT(URGZ(YWXP9l*6o}D9=@VJL7Cn8eyFR z{p?0IcEYeVc(c0 z5~dGKr$gHLaQNt9E!x|!72)H)qhWk<)O~A-lVpV5En2}Y`(uwEO^=;ADtqYq;0KFM zMUoy&)Ww8@KGDP^hXT7UK!)+?XzK1&W_5(T6GzLrh~+qD2C`SRI-hp-%Ce3LgVRaT~cqHD${uH7M;2zH?#(0arA+ zu&i00n`u5Zmgm!m#>-|ju`r>@i(!qSvBQL8t2M0QT?`|#?_U^7W4cFUMkh5Gx61jo zD_4Ej=FO`&Rkj(2QN*y0qMh>qJJB|@`{0l+0DnW{Y%jj`Vmk2n{xsKEOsiu9>E_CO z`fBNB`eNaFx~xft0WA_8*Vr2;gLpwIX>mG*TKa)W1&(!^(1Z(bPQYfOgGnk+MoCr& zbo7z~iCRga`xQb1Q-V&pT8EiHjTegdoVWo1lV`)a7luYT_wjy2%BCN1NFL$O5tqg(Qbi%X2%zNqkWlsKlFel=yKg?sYr<#l6_; z@!}WX-KD&auN@bKx0nLwcjlSxD&)LbY0gZyk4Ta}7;dNn(l$M|>a8tm<@?uH)8GEp z-=v@a^jBJmE`Wj>(ikA-(cyG6$EVZ3|C4{`mF|a+9a1BRVH0h`(s4S>7_V`z9Y^aZ zyeB8jeN+{ED^Jc_c*!Fe{8`10&2VxLTfA|rfeQw(aEp%~zs5ye8AflC$Ac4ju&Nq% zgW1%j^>pp>ZCynC%k*1KDy-@FAudtil4f)&yD@Z)MEdQo-dE+jmR|VwGgi`piHbTD zCoA!>ujuT=HO|)7gfdrg-f>Buedf?X3nUa0^GhKkN% zNRx<*KafN^NGnXT3ox=jGJA&uMHlY|#prfK`c4ga+KuI`ALI%fWh(<)9-D;m86uk_ z5g0O!DP}oJveP%Q)}!7jo}GlVrk2ic z=WX9|v_+)b!Q-v*Tj1;DT`!CHM-9`2?3V`cwDX~f<=)t=Uut4VtljAE1lx_ulCJXJ z3a*=fyL+LnynEpXr*g2wu$eAN6OL@P&VXox|1Z^b2< zrd>v3Ol38^$^6{`RqVII{N;o|-42_1hG(R_+mR6RcKdnV~LmJq0Xz8wX?m#pw zwU7qsOA^tVQQIy14si|c;D$SxOyeCCw>K_ZwL?S=8kYe z4s8z*24CP>wBd-0`dGhJpOg=|nGS*w`R*~u=)j-berodRPQ)>&hyh&bOlx5J)(qli z66i~NF|0aBgIeS{uSJMoeR@In75;5{Uw3d^xpdVNOzd8O=3tq^GjyPV>yh!{^u(D{ z>AOFCE4}>f7t`3pgy0xBmn@kml1&`a+xq4%O~wlNMtxThrE~?gGzV02^(9uNjcX@e9lM5?pd}B;((aQYc(KMlFL`Oe!hr){M!>H?4 zwGxNwi&TxcKGU&9;AHi^_<{W3*N~;bc#!H#o^|Efs@n1!I?LN}fkH&oV``D&+6?ZyV7d1in!FwNg90j_eK8y%LIP@Zau}>YENYA|b zMEdXkr$0z(-%wf_UQYAFD`{5w`o+>UO)6YVmsaM|$_TQrAfne*JG4rcm^>I0eLNF- zI0k7#cNuU;1p!E_1k(}12E^7!xMrCNy5hI)1jWb$3y6`GGBGwhqFos}3dt8&i$^gY zg^q$gjSG4bLj2JrTahzhMn^RcsSLr+gI_Nb^4JTaw;z13*xnm-&-y;}6AL|a2AP(` z7UgFN&);zj3*u{_8cbkphUpxG@ye?j%(5(nz#^Cy-Ymbp1P!HM7j*E}zlpuj;y5$? zrd;+;#_>^*`OAf<6W$Vb6MndoahEr+*!`WO4L`Z5(DH}zb}=a#b}6iB0DVZ8NiEMW zr}Lj*NPqPgf8#q9Ru`lhsl?z94+@WQ5)F)>)fsQ!`_Z@3ORqnb_8rztq_ilcpb$26 zX9LF{asFMUuN1C2dNc~xbU^|%RjH`Ay=uoHMKFai zLVmRs5066L;S2Hl zZU|M@N@iJ3+^^~90%td5Nu8q*nvIwb^yk2uhc14vgVdFlpF>1JY$Ff`%o z@>!KV1RQ}bavH*oc;8cF+O~+(WpN9bdqwni@?kqP^@Gk6b6+&1Jkc+bF5InnOh_^}0Zei^Y4_tsNmCTo5R?vLldnd*Ps4oPi znDqs`2`J*}jH5s9b`HBKqPy!LzQg)f)0u}Q?^}&H{6ga1#UMS`gEMN6t)gK+& z&|sdz_)^xYWH#d#V9gZwHu*N;0;{}>C3Gt^di;^Hp`dO6)v(%%JEjDUul!pa0SY_6 z=n>Lcf;KI3Y%{<&;RuOCaV>bWdj%bA zCXV=1zh8aohWq!D9rdRNv}57dzj!bG@~7|U_^VISl?zwXoQ@V=*5El~5AZ$eOZ2xs z)oDiahrZ>O)t#shXK1~crmKg8QgPv!+f%l4Kj&Tj8 zOUOJ%l<^Y>PnmFF*9EC(lHqA@qvs#}5zN~y6*A9j?O(I^opku2)`qX7vfyL(-Z&~M( zOyMIA1=TTE16qjrV*249{V+AAbuYuna$3@5&9g(x>GQd(=@$!E(}nf9w641rZmAS; zA+?6x6raZP$VdHg9Tucd|BlbO%7^p~Cwhbjp2dhD2y=N; z8PgkiS=31tzHgI>45e{&RJ%8{lLQm8Lr#enCNKzZZ6}^74#voEV}caJW!KIT#;H0Q z;x8VugRbdNJRZF>2fmRL3#CEZE(P9_WAUO1iQWiB?Bd+Ajz9V`{p_D~;gIp`%Xsudl;J7y!krJ^fg*kt-gzUUjY<5_s+l#V zjRDgzfy==anB-7-bFuY>FRy9_UdKXRxh_ygTm>ngr=EB$oqqbHwr)yF(WLgJW zVqiAng}21hN}pxMK24&dvm9kNB5VEvU&>e0^^Kesup3-w93%DY?o3-T=iyCJwg?j$u9$dm+hprd&0 zv9xta$(=jZcu@LK+eKQ*TrHr>TJ8J6wo=<8IHEGY3cawWdgMXXX@8Kqu~qjZ6-!8=$cq~NID#86?*u7x^2O+j{VzN!#L+X+V# zb`yGg)x*f{4DTFQ4O5G?qM^mL{sF%7Yz3YD5wa~S#TuQV9O{v7wu6$~@t`qzr3_X$ zVr0C+yTydki;)yRI+gK}CEcg-%U{2je)jh|CE=GJq>Eo(X6{IJ7^nSnioZT2C??nV zpi8*b4`0^;(b>iMG&?(&u3fvHu4?RpQwCmrkhp^o>l)s&*@^FY7b~v57&|aUD}MJv6R`{+d|O zcn%jLGv)vbd|9!0&j^jfD`B`Z4o5x7wULo8a&`Vi38Os$XK2&o@3Nego+lH;SO(@t zP6j`aZ}-mk=O&b2`~}L58X%k>Ko6d*S`BwI{nPa zv@xd1jK-=CVOmNzm*&#t*_&xjM`kc?qzx-+S~9?U1o>2kar|L}9SRzIo6yAM437rz zrgcKe*vO~{^XZki3#0ULu{3ETJ(~?g>0x}%Z@%@ZIOgZITS5y`;TgLWM#r>6ggZl! zPmg&;3JFnUizvLK!Gaa;o-{L%e*_(I5Km=HTDXU<72i65Rq)?%%buNBD1zb!YmZDs z)+Mu*DdBjR2T$xVP#o@7yrt0LUkN?_6*`BjeKoDNH6t(a20fubz-dJhiebpEqT>E~K;{x^U15B?72nd8lLj*JbbeLDW=?H|3K zzWvrq>4eVdTh{>bsuU6fEL>bOq#0SNC^ra+OD!SkJta=@*^i1*>(sfVNE$&PH9B*i z+7S;uGQpxy<>3SzN{bwkOMdIs(-_*Y0Y+u&tyhWw<%&Y(+p9jZwqjcyjXw(zmjGThBG-UH?heEysOMq7krW6NH}&LCMZg{ zoKU~O!vhTOV!#Dm09Ag(BvWvfH*id9xCJ9ZWoH72%XO%P11gAXrO~|pnu^UmA|7zxfMae!LaC0z#K@S zc;qH9Cne79plzhfy1S1!whI#V!8Qo#6oE6hlSjX9TgQ*a z-5RRj_%_3Z%o0g6mayr!>9jdu4;OgbFKcMul`_x+Wz(yoDJ&6Bj?GTloibO!MsT1k zi%o1*)Fz{HR~c4V;H!2+jXxFG7M4ua7Et%!o3z8KrhX*x9jM36|3Ks4tjD5_MOwnI zQ*eAADnnA+g{(sY?`He6y&cgZ`fyT)bjZ=J;M?&iWuzZkO8G-R%)aeJ)C2Z@)N4J+ z>FO1wCQ47#|DE{&pD2%B9Zn^X`Qr9xWMJp|_~Bw={hG8$RgEGGrnA#$LwyZyMO0zsuhNk8(KmhB~&>h!C(Njq2qfn@XKf6!vsOi z&o89E_>X^?K7Q|uG<#irX){OhyHCy8_3)Tg-GqfMJwgSZ4c#r^b8qCyJq#bc`$@W` zRqf+b!|CMX#}r0XXdsuEkrCRpgF0)Uij$S*KJpkSFhmSlC0n1jj(gZ|4RydefGYupBI za-RY_4JbXUvacw;^MWg56igV16XKP2>S^MDX#!;h*`l3A$`g5!*TY(5JFHH^s0ifP zp&7+Lt=$r18q9C#SR`>VeWW9!zx@1b9oZy4dcuhDFvTQ3dp1P0ajey$69>|de*XvQ z+{;g;p>gdG-1&#mPhpbitQ35w~ zH=7OJrJ#um?IdAWHj7iyX2p=i&=U%-G{ZB-Gm)`1O^n3RZbjyZq`QH>RvNp*cJ%3u zzGINR=Gbv!p%n0v7?el5Tk*SJ@9oeN0wv*BIxtp-5^TTaR^BdTS=i*rf8~cLmG2O% zkdA`0;B^FDeq1PAKtjm*H7LBSdjqarxRL(qKmT2N=NIp4Qb9Te zzo^kn0w}pp=)~iP)1Um$f0Ukl=5*SBcv=bpg~;qI>OlZHPD#WQA(Z&dT?^FT+%w=^ z3x1nGDRUIHRC%G*Jd+#VykG&^n{PeD2mV+ICmIzF#m{7b0Swvw2Y4n4c%$na%v%KI z=4eo~tEw2d1R>%+a{O=_pBN9Mf^k@2qjolp*c;mZb@<4^G@==DZ&elXMRAfCX3H74 z556iqlLVxZgkka|LzU;mQJ&%tY@vsP>GuozGe6=<;D(eKjc*}f57{J zX+!(ya}RT%E;BTudYKS2%5lJ<$Fc~jd`R}?;E6;RBxF?BdZXMFUBQhTe=+6Cc^ZtrEblsN_Oh@+6y3;6@!mzy?spORc( znl094X57x=6_Vb$RzpiPz4D@4{?$-a;w86cUN>!Z&^j#c6a_Dk=|F?&D*C@4I$9!< zly|_{2J%3BG8p0=xk_Ug*I?zN_Ev)2=-!;LqL*mTR%IM~n?Y;%_O^9P-E)_c*KfQf zPap$(zu~q^_IB{rY1(c)4;(zjq38#QLq}vi+KE<*13UC%u^j#3Xew02E>N@v#ZwQ_ z&`rbOMAyo?3kQq`mDRsiUreKbLM;4hlt2zN`KuS~CqCfAHCn^K8H4(ZxlgW+BJp?;9lp>{K!?*yEvl#&aLdM4aZT-N zD_7E*`5(ursO6w<_~Hhc-h9*dXF;PVHf@kX8zU$x$a}v*8T5671EQ%nTjZikka8>* zX&z9172~+XC+Idy*9qh+g+E@dL+!zWxQPL3?2Eyu;k!|E@vQ$aDNDGj{L=y0NG9mk}76~Zkz zjfeg2w;!c1zqqJ;bd>Bj7=MRG_4j)(^092M?$5yE`Mvc5UiX;PC4Gn!P`uStay!!zl?_)MA@pB6PC z3+Nqqr+EwK^APf&u^;Y%qiv5&FoA^(K!1EoUKNj`@zL40Z%f7&7v&e_esXeBI~KS& z92qA;$WP#k9Wqf?8S^PHWzvcX28{Jb#^>;&q1~7k(b+Dy?Qreiy~}z#^xppNII*y| zleC%oB9!>puEL6Ph$jRDcO3jST!w9NEB=Ufm;%pN-JZQ$UPMv!C$I<_-^E@BF0c$B zq0(LOo6!a+wWJU@URinb=YJ5mP}Q;auv4xmEX0Pks+kzXGPb~-unS6t#NGG2V?`SkrCznPvn_e`3a znbetlOe6rJod@2DpgCMAHm}F5Iv`RBwgCNmVzfM$=;D^4eh zOGauS>EPi5nh92A0j^9>PzbBm(;-bfeD}w%r>D-H z(JBVPsq)Lo5h4_|pphjR2KtK~C*Y9N;FKyw8U^{7sbQU&M>c(}9Rt68N0&Tls~r;p z&L7f9?I<%bH6}VAOJ_APv~S;xTvXW!50eP{G*NWs>pIu*!dDm5haY_6H@iD12hhC%LEIXo&&jEY^x9h-n{`IWQ6Mk9{CoL1vPVA;BnK=P%6{JW(uBlyjUMjinhP#Y|hIL23 zQ8j`6-WgZzRdkW|tx9EZrwY{75_NP}Z!;{o*#qVQ_Nv6^9@5w^?|YF{q`nu7{Up%6 zV59EczPvhIFT^#%+Ob!`DvQJj_-zzE!*^B+wU{3aE0YRd%~|C{E#Wm1LmkP_-tAng z0a|-8fPkBR?uQW9$@zxcAGKc}Gy$OJF5{m%Z8$`26%cIz(Xn&s@{eW?+}?6gvfwS&EzcuO4p@ zh{Cz6OEAJ2>2CahsW4H9A!;jwLd^s~Ym`$tRP8=-mCuaIyASj+H8YWZ{Chu0f0OX6(x5$) z0*r2HQb&L5!sGTSbm&>v2z^6HMhC=SEzTX+Gp#Y6qmwhbym?r=6?BNz;##_V<*Ifo ze3hTHy6fWTbfZQl%uEP%0#_vQfKFqemZV!<+%Gt~ly zG}shDE8h|$ITaY*V4?#S=R#jaOLogaHsdzVC?244x>(s*i_N%7(n=Z&!j?BYYtm48Nt1Z?T*`rq ziNE^veEQYT-bugy*WaZ1o4S}s@eOLi0A)u-Mmo(G4FVrMdL*5G{BiGaxS<=Q7$Dw| z7Uqru6t~xP=E(?$t58v3$H${i+0AuKj_4t&KLQaX%nv|r^nK{7oPOQ0<*OdY2-Po zky&C_R!Wn@xe=l?J83b@j$E*FV_3>vO!I`o$nbKL?0=_^O*GKOModYGe>vWQn+ci50v)wqh3m?rqBvYxRTeSkeB+4~iUEMyD}#9WMxM)2AC)LbhxrfWZN}9_o7;9i zsnhNhN2mClmKwQLS_F)}<`u!~4sHIfr;V^vOs#HP!>W|sjRd!nao4`;Y`SZav>}c< zv(0fkpUS&Fv(H$2XHW0^b37fGL}$1kL(ul%;B2Sg-|db)A^$~BX*vGu?OA5%rAp}rOz4l zEjS}i!^p~a{1pV{4Fqw3kn}`2eBD-5gUBv`$j!ZGK=l_m0R*DAsoEQa)e|9vO>|Kg zm=8jMyH76&cJxNNZQnvCDe1=u9#Hc#(i{{9o>*2_!=r@JuO8C?`77UkDc!!M3wd?4 z(dQq0mDZM()_lYj_aGuc@Y3TRy!nEUg*5Smg@Wf^Jd?ir{WsF7(#U@d-_?Xs^Jib`mIgLmDU1 zRR?KIgYCC;Jkq?5=UCPl$EtX{t`jBJ8Iv-9Qd*J>O^T09EHo6hp|K6tNirHStaur# zF~7w!771}Q^q}%jdnD4(iYACSenHU-G<=381uXBItu|sKChi5RUu)4aUqmSm&f=-3tc%cyu_z$BJRQ5H@ zZ#%ms*5tmTqqms&nADDqDNQQOXtD6V!7VSM?x;$S@N`qk^{{8#7G^0Fp9^*5$dJB}Vd;)#VgR*Uf)8UU&A1AE_}A?fe0DE*fQdLALO)cz&_?yqG=&~q>h>l? z(^ikS*ck+2@__gC%U9B`{^eKc?{ufa^1LF`!1M;wwo+Ca>ep*{nTX&A=`i2&o7M4b+hcda#LRGd;@n9O&QoD} za={Y}Mkkoao03-YEMrwA5d0ZrR=r-IS zP&A5%V}?e@RMf|G1eI3z52=<5o)JJakroHegqoLO=Xff3%lV4XJ))U zRia~)qESlUlNNZ&uFO(6sz-RP3R05?W79eVZ$=f2B4eAXw*jgG@cEq3uayA&!B>$a zBfiT{=W}&g5i$WKWGf4+>C+ECPoI7Cg=wK-6gi|oiW){t&WxqUG@0?_(`V9<(#=HF zAQc~wB+bdAASw(m;x`ZQ3JWp_UcSf|e*>p)en1hBINTjmWbp1_|7KzBFs6#MX4&7n zyeKkg8K!K}IIDyl-y*wN$o--WBEjDbvz@e3&hM8Y57D?W;3kU|bD@WKJfZW**)W7d z(V{=C6w7Z0ppIa2o$bb(OhLcNqd$kJJNkp&4pe)2C7#Z4DUnWpGa<}1=cHFrwtrV1 z9W1?I9ZNH+4xt}8wR4~sKigsN^n0uP>BpZkxD!J7mRS-HhwC|9sd7*H^MKCzs$;);b|`jEf6mFh>T&qv2oSba`=#%dx;+mfORD)*lh zKj3P0bIcH3OV{b{7|U7huIPKqgGF}w2fixpeAodO0)q@fWLJOJuEraEx{yytD7>_d zWV|>pdW$sk>}x!-xf8CiB@Pm4RBAsHouj1xsPPH@gn<4itQt`NlE1)AMEBD`P{dDf zfG2oGcDGW@gCBV}M)#u*3#_I37^Q zKB~d=C!al?{`>#%2kF$4C(@W!^i$z+VehgAs2M*n->P5=82`q%Cw|OI z#pQJA`~~x6er`TpUB9ejf2PvmV+S?fp@WHZr-kR{8WU+!cK|GC_5SS2Vp`JO0LwZ- zfx-Hv)dh*A_$*#%gvZATX{_F3Lk%$-zQ-g3`4?Ow7gvx|%dE!A1gF~+(v0phIM|rh zaY!SQOKxOnq=j2I(yw*g_^q3oG$Qka!wW4XxGYi~Q@)*h@rm^2_r8^$f8iPBZ|nqE z)BT(5HksEES(KYmoe)Bn4{IVsS0wuxRw3s2r420%UgtCn4f0RwUWX|?T-H3LiG>l) zNYe1lYStBu6G?L@l+O_fd6FD*8U{ygp-a{^12Ld-hMr)j0s_VfAGbuy#fz6UvGBFh zp)gGFOiiYfCr_mDu`!&)8S*FKVHfry|N7k8kP!qFo^Hn8-8>(;D{miCQ*ZQCcH~5b zk?-PG?A=7D__qsL(;b|k4Gai%CwVOXu-gL{l1ygo%OD1CU^;&X?&1dyO!+Rj>+iZf zpyRj1(;gmo3MHn5jj!f!H}@t)Gc2%8cqtIAIA6TIlK%WZ{g?FKFMg*T2=bSbi;9mq zOXj}|0aRY9q@|30^^5n?2k(DkrCYqsUs%E*GRE3AcoYt8zhnuh#qY-0P&)efeyx&! zTSxRfDOBDNCI#jU@mYiBg_Kcj-BVLcj3EkWja3#jkYumY*duO5OWJ_tN@8YgeKC=a zJpz{}w3JXd&|okg9jHoXNoU$MbdkV5!@`0?-15Si6pVOCtBOT@%B+%EO*KX(dV!+q7Tt}lx|*n5RL&gB-uvC zrB8#ynnTylfh9EyIBQih;GHI_45(68xHuXa@I-`Wyfu5z zt`c@EKo|7^M8U6NRZ@dGuW$bLQu^@SkJA^Qe5E6zRN)aF@zJ_7yo01uXHKS9UVlmX ztX(Ne6HPuckvP~c$5vJf`tl)pThB5L``JrTR)_e_~1(kxJ&P5x!EOz zyM_j7*v-^7dA@5T))09DulC)^ie9kjld*fqSPna}k7TsDM;R}U==rE?c9Puf%I0?A zyU}zfG)m_G&0UL(Qsaeqp?Ta-jW(@2C(g5Iwey?H4+HTm(xWrVK)VOt z3V^>nWs8e5zv#e?`-Rsqb z)ctT+u~!Uv?tJDrBwbLve<~e4aWvhyazhIqm(r|`>G|lBkJIepY&v%0SURL*k;Xjv zM12_P9@j+6n$8enK_!om?*Xf1X<3=Xq(T|ef`8;2MRHh<95G0~k*^CHXdloxjN9(Y z5Uyw**FZl<5e;xTvli^~zIA;*{rso@szLeNmR+jcbsam6P>*Y;%IT+$r9b|YKhpb@ z<-DPVry3^FIL}C$9-T?U$Hvl<(!HWQU03E0YDWvVfpWhBG%>m0@g32%ioYlt77fF? zOVu9GA-_Bv6!e;n=m65IRO+!#R~fdhLC7RyK5|TYYH@KcUHJN9x^UrQx_(_JV;FuT zO-)ax!$%LL{rmTMJu9P#kwe7>2f?{JrYyQ94iS+be?0oBabWus3&=o~O}mv{HHvoG z>Iy+PQKUuXmnfn_DPQ?bl0s@)Jiucd8DRxFVY+PL-!lhO*ZQ z1+evaOFS*_5>F?@+i)v5w+gZeP6~)^bvG_+tJwK#>Gq8|5iKlI91QG6g-lHq6AZ?$ zFzh&((}g&`oVSRY`2;icpyYY%wgpv$7jK8Pa(_&F&3rbUDz+6VXAeL!u^B2kyw-$Yb*5yl=m39YE{K5(GlpQ_eGb8E5nPcgRv!}f(Ceknb zqMey=CW^c=#(_j2yeBNQ%Fl7>!=HS#8xjT_CV;~Jjrb+O_s_ukVEQi6kh2mw+sXhz zndl%9TLtaQ->S^WOAO`hm@6DETl%t|(z}q(H#q2~p-lA}sW^CR0mYz~8v002M$Nkl3po zueaO%9(`|v11ewq)$^yW*GlQKn~RxN9_xbBVV!ldvd&epGPz>(ATQtUi=ym=mk}Zj z8V~5ab^4jB>cdht>ABL%EcrN~#vA?8h6Xy98I@$DQF!t9K&I>881#c!4k^&_>sp|u zx5CmNj{4I*Vxq7i&hd$fv^=*UvZ7B&C*0oZv!gO%l0hlNE?nSbf`i5bN6q-i=-f7r zfLeX8MRH?f<7stqNpUMIJYY~W>npbb;Di1hMoOJ49qF_4&46%yP@DmC^)5{_ed(ME zw_gO_Y{x*Oq2kxpUHbuNc>*b4C6A(JYF1*DPZrOSjzQ%wlRYD2I#%YfLn(dhxpeFH zZ2Fgf`c?Yux5gg9hB@`F<1(kEFa|Mb$t zf(Gj;b7X+>3;zX=5o!EHba)6za_r-Y1j3yFYCx60lmqJx(R4d~_4)a9>FPD_8c{H~ zZluHGDV=@pRQkyu{vbV}9VQbxdBSulEkim+iJ3WeK@96?Zr!GtxJN-tF%=h=Hgo(E zG>JCo(jO&wT|&a7Nbo6kC0I;|QZS-cyu%kB%1xeJ{2XIV!iAl2GRmT$m$W0~mhP^& zeED*^bm@|gb(+^g)@6m$CDGCg9F>0L$YD<^IMVphB334BPy(V$_=q=Rb<`bxCIFvKM$@TN&=$9Peb_9MEg%onN1fj75JuXzKe0dz;;IF(9ve~;nxpP|U+j=_ zH%(!<-#ISq-J-4gSs>d9B%lRXZ`t83Vahww;*f30ih!nrpwN(U`z>!%1>RkM!Tv9P`uc&XTvwiHMD;sU_>Nn*NDYUZ)h51Nds}1Z>V!rVAD7)RbB*trK6c#vE1#vnWHA`U;T09jKdG@#1E+c-r>Lr40cfLAozJS2aZ10LEx zrBNs=j+Pz#y+c4sSiubI%d7|xoC7*|(|l!CnX{k*4ZaRxjw?JzOR=Jb%7R8jQq%gR4Q$_+)YNfA7}q$HxJZCBDkr9p!P1fFn_2jLe~NnbjDS$8XZly2S7?7LzC zv%p=Mq3AhI>DZ~mo>`sHa?~upkj?j2xVptbG3hUC zzi{=Kpu3rhx=+}N*=ggQ(^0Rd3{?Ok$lm-_8qDTR%&7A|>{&y1OeY$+iGpDOOx>+5 zSRH+U_JVE;d^bO9sSu6jE6Pc;gfxAiMcw*ZdI$qgOW2~`yHZ^XyB(NK^4K--H_)RW zp6-w>J*2uLM$3T&$g2VcQ6~+*@Yh>L?|J0m#jZWz_$X``Eayb=8r!D@#kt z1N2<FlKiHeZWcG^;b*{nRM|08C6yY6bYRcbntk!G98J;`OFqhar$?9?{~# z#t5~%`o`eZA9~ij$zg`HlOMeEIthSD4dW$2b=)Bpf>$Qz)OV03AcMI|dfw3_K;z7~d# zI>PAK$;0WpKYU$0U)5@H4U~U*#bgoAVM)54>3x&w%+n{+_ka9GI`{mOKC9nHZpsfH zk+Y0#40+@mH&G$GIpvPH(0UxEo{D<<3#D~`?7-CF+WmL`J7R<$wH=f`CS?Xyjt=R3 z|2dug&sfFnt2Z=RwU%ycLF^Kf40_LMQf%Lm13DUM(i1X_=M3vu=7wZDZYlsH<5A*@ zWS)nGtLs|gPa9=OGQ(X9OfG~ZF^;0#RT*7lH-Kc5oj3zpXzAmF$RQV`!E{06B_DtA znetHEO>!|Y+cfffN;?%^*JaIbefL#O-s$3Fg^9`gTv}AFVlRTysl19Ir+{gQ0S!$q za8wg?kphl3f<8|akpHp|iAU%m=mLrf503%Kjth%c<1W+aGFEP~vOw{C|PzKd^Z zOyP#zuvP*E*>^0Ii8f+i-4gb}PE|26FyWA?MjUGNoS5_boE-L!XyB)NTlT{boRj{I63j#3hq>pq#I}hIt@F)_( zSCcW2UQA8`>48P@X>LT@N);R{K~`yOvSNc2h+m#LW`>%hi`+2OEU^yLgP%ne|0!bAn6^M<{`|Ll`Dq^XqH{{3VL=bgND*}G|-tW@Y%bI;xr5%C; z?~F3qgu?&ai|2G%^kc4^Il9S{6N;1Vm%jX4bg+toq(BXgs>nOhEkHJzx#=;z@yV>;D@d&u3KZ+rqbIQ;m#m>kU^)bN>4N*`ii63 zV(JWD;q2_))wKtyUBlcT4=BC2)T$=GD@jGRcY@ivLIK^jtFr=RJQ;6pTC~uP_f|60 z5}?CJ6K_58pr-@)9(1D3R?=3OT2BUk#XobSe3o#f!Rri(mUDz@a<7i@k^Sy)pi^Dj zE2e(yU*NWjn|OB;rWd^rgI}o&DLSE>ROT4)r|;xNU=%I}bk{YAIHKc)hSLK4m$$l+ z?rX1W5P3-bC*K1IQ{=~({e%V`r>4iE|0ZWJdDoDl@Ia_=dSYQpm)2^x`8;Rl2*~Tk z)Nl73Mea8`u<)w;m8K?j?z~pJ(o?^oBRotttn1D_ z%a7$!Sus_`*wQ!N3Uzb zjC}_T=rakYyAwu6oGuNLDc>|sqETYX)iCkt)C!O5K#!CSA&{-ug%Ek@aT|#V<%B_f zpR6V*5vg5LI#NlYRwNJXfPj8vVncCpr1pX)VHOt_(}Iqax_R@aj&HiF$Tsf|G^D>%>awM_rnIKqZYPqEp)r`P|O3m>{ps9KysA+lENwH**jk7 zk+P9S2*zO7KYk_^!qoWit^pkM-)f>Vg_V_ehCxAkfEg9apuHMGRJk-FXzT>@bNcyg zG2#@_>4zC&P`2}~1-yI+zJwzzf94I78LkwR7A1~tmQ*gx_<9zAIHXLuaEYA){KEn+ zcIL7lDrXQw2NfLQskE6Tw#o306CMf@#mslz7^j?=RInkB^uQ@1WJa6{W@%L`T=>&B z7wypKW43xoH3vH@*oJ2o>P?m7V~iqNWv3=`RCz%2fZ*3?ZPMsLCKR$k$2tPfBS&?y4F!=fAv=e)G$Bw9+Q-W^lq4nsz9tSp716bX?mp&!j^-cMraiPb=aL zNgfm)Q7fD@u?nTo3LM;Nny@2Bb=UAG@8H2sO_Tph>t^&jMZ0IiMVZ)r^4+N_{0))5 z!$?3@5Z)oG2Q)a^gfjl187QjsQ;j$}QV&~>I8A+Kfz(?ww@Tewp2^Zky0*V()W5bl)cv}q(mZNbZTiw>+Q7>C(Su9OIO6Xyeg zi>@Wi<%zadXzCg>vp@Q-rRcWgNwjYZoA9C@K<_;=cZUPC$1MLX`EYmQx)ZFWe|aZ3 z9`3Ld6go$_z~uhW6K77Pt6IstyfBv*XP4dgraaL$D19LsRr=RpaZ1Ne984!p9XG3d zOs)D*3?!;sr7%qR>BtlvuXOh8lj-J-dEF1Vnil4D4K2l9PZ1;X3&StR5uZAJEFIMu z^b}?W^-;!*VX%OW0dWmer*Z9qc<#Ap(t`SYt6yL8(I`xOc#u`$pwWcO!m9X*$~!KC^#1J^SprG_RH1SFc|83Tckkb>9pEdFYxd z?6CT|GdfHEsdG$!y1crPu1sCjoeS5~qRIgBzb3vfXu@jg@{M%u+HLPt7-sxL%xCalOpr`! zQcFzm&H$Ju8C_FF$2A4icMQ(6OF$mZQ_3HM^Fk(>)zL)Cle15rPE!Y`(vWu6XrM=T zKCGt87cQl*zd9f6bple_e`qFs|0l1d7hgV`_8-usoo;?+LSb1uEiUK?r_Vn9LNXv1 zSF}n1V>L=h>8GrLi=tZ$%cx9dHPX+X`Hm%Tj zWz)nGHvN(u;za2UG83y9tpN3nauY6m~$khNO5YNdhK4 zgmHYr&s)0Iu!1&!jg`s?1`wAbbcN2R;}#zK2^(+%in|d&EFk>EM1J{UwFN6GFeqj3 ze$X5LK&M}5DPJWL0?61)Z|KDE9d^Z>^9fqn#>Q+hhHoYZR&-p`>V_Jp1Dc1{WWZXb zQA9WRcvvp$N{Y0Iu0osRga4W&QRPBqMC6d?Mkyrgq1yiX!k6jO&p*~#dzbVjewz&N znniw3?w?4{zk1deP;n7e+(RLhBuGf18CgWyLCh}({_^FDQuZK=F!{l)jc0^2oPYT0 z&p6KoTH@Yj@cts%V^D<`yH7Uxw>LOk%3j|*AXyz4hg1FO8(#q=@D5p4WV@i+?RO`# zy0BrlqrPYOoTdJ0i?UPV+d$%TGhoqeUaQw$9u!y99PP;Z69ki5k8<0B+#c`N-^A&7 zw+3y4*iNYJz=|I8RblG9Xp4PEpLYS}j_BBxK-=T#)=|Rjo#1R{5>MMCAQC)?#i3PmPua35M-BI;-h^9o<77|*!c(p<9{IeB*$xQk;DoHxKbEYZ^PgXkUH1%Vg*oGgKr==+s*%c3?Nm5+>R@{D_2<*sXU~QwI5KBgTNQD! zYP*r1f9=_{pxp}VBU%LZ)g|pJZ{nqoJvgd`Z#u$+9SDz|I+R{{k9U*%Il;L>M`&a-%VlN$uXbb^TVFy)~QWwEBKk$5(G?HwbbK zT^w&jyL*7K5G{CQXrCg$bWe=zXk0{NCcdm$?jAy6u%9rw4iz>$6F`B%=SAN=q;DkI0V+J96SHK4CS{mWObDa-|p zv0RBbEGvwcNHNIH7zk*zG*nc|#W=l$)OpMh4{>{JP5y)#6df$x#qLu#WL=fwU~*wa z_F;u#ashsDiiB}0Gvyrd0izuW6BAP|-}?{Dqys9C`?dQ6ku^L~WmCp!e7u=F%QyIf z2^lcIpv(pfLZo-V#&7oB{1RLhcC+s{-lZA_+>#T)b7fQ$64uu~?GfJ+4B$5bH=$d+ z4BNz8vbOHB+1|vp*>8I;A;!c?io(%ZriLc--?+U-dkrKide%W3C zyO?PQv0W%EA)0&+-~6dgFOt&+OncU_Ym434Al z0C4(fUFAV~@bIAUN@W=&NJ;9Cpa3U2Q|~GRrDAF7|35z{3#vO`Fg|VUSmX)EP2 z{8O+(fUHxR6+d`X+ekIo4}VnG$X_(lGNm@v3JLGrfDFayI~IJ*74hp)JU4D%)$ILJ zI(|kMOJ!$A0Usj!(vgz~)2na3m?md*)ey8nw}8COPy~@{+76*N2rxmUSKm0%!ADCR z;RFway)tYqSO-4Zx9bo2UK#9xxN^Fe7E{8H(|IprZjGzPkyejg_@aRm!mIhyiWQj|(p~(V!6ht?l}9_4?a|r}-6GrVo_F(G>8TeZ z7pu1TswB6$-{~hDEL`k`Ydi55d=}jbDXv>%=}r$aVeKb+OAHygK8dT%HKMCCwxXqJ zfg-OqZaT?UtHfK}@5M(i9`*8Tr)@j1!77)!E%?-HR%8(4B@f~v)AhBd~`&Q+*(nX-5SUxleGRTwOtU0Xo5`LP*e-KEfprc{qLZ8X#e zfBL8vfIX82r?txZ!wH=MuU!VZXjWsN9$0T^tZ`JwqfF0CrqdeufA*zw>GXV9Z z>b#*F=?{};mrX@7I5vEQAG>PM>or+y%GgLl9;FY80)ab&8Io_ytWnj4x+BZnI4z?ajyz;#aj%)m=l z4r;<-QWwu2K71%WdG<_tQ4<`K2gU=h`ozc#rxeiLM2WDtY~sLJdiwPzjd%R;NIL)Z zMbpcv!rZTGTEMFdVvip`n$DhkD!uTnXH#Rck@1QTOn@+uo5u@0H{vo$8we9}Y1y#s z6**AF6)p!BX7~~Pf#5w&!a|Er)ez1hR*@@TSO^Ip z3j~xEzJTcbP8nge2S3rPvSc~JMRxuO z#E^-{GW6yY2KW_=Q8jR{@t$$*)EQTtBcqIK306CNzWnOTbnWJK&6NpWLoZya{p7Q! z(s$l|EuGUu;jktT87Em)8M&!Rkgv3};o_Am9@jD*OnNwfh)-!%`aF)LLJ|}Ne~~!^ z$0Fc-04{QrT@R*HAw02Z{z(_`mYZ`P*vv~#<6vCKWmaX&#~$e}l!Mw0a_rbq?MRyN zE+ly3olRMGkT=4_bdBOv{;+EXx1gR7t%kUpM!u1p=DYGN9+g8_gnJXxHjr=X=fMB| zm;YNkzc=y1nJl??#*ul-OzbXM$_=|qL{KPA7&2KaoEbo@g+UQV*!E~}C#z9_y^RGJ zIWP@d!8Vf@g_r`+e7x<30Z0aQv6Quqd;{(;wf>ozl~o2F;Lx%t-#?@w-<;`ZpE*9iv{pyRof&>`K4=KC1t}#$91<95*nr5b{DOYuR+d?~io#F?s@S;6T-OpjO zk`}3iGI-~((A3I51M8=y+#vM&uN%tFnkF!=lsg^T(_X>^E-LHLKgb37RhPNI9kgSIjdsJniw3sV%j$MeLy2kI zB9P@R!;oJkJ)MHNv03v7+TeA~vCUf2&>j}B;@1|$a?|cszDqeR-(BBAzEj?H#feAG z>n03|uQy3Z6^&zSvL;v$8#)=JPK2ElivI>xub2ck8b^<#D$dI?+|)>g3ms2bg}~+QU7t-*+mAr5@X^ z(A{ag_n4tO)pt7~(=xV`fc=J1Vs=l4h0OhyTI5=Q)xGwp!R^4h9lD#aqLCwBJV9XX zn`5r!YIOumohbZy?z}dpc+`ss;?L8hu^w;JonutGR>ZAA;;ReJZt-+NvDr=aAAL6Z zuDL(l-lUTnkjZ^2t-$Wq4sX^M!GO{i1}{0&o>mr)G0fvWmimM4Cq!gGcQG-L3r^!T z;wC7;;RjFP6}`@9C!z^hvy~PPtLLGa_H>g6k-1I0OiIMr?nYa~v$YjmTA0{IbA*FF zICyuB)7qUCn<6(=o9K-^C}SlI_(p%8e(@$0n;d$}Yby_Y!oVgBO^406AfTNH$%1u2 z2}goTxyRm`z|A;IxblX^&Ebk~Js;5vMV2^;VQFzO&Ck!L+1WWyip^^xZ&7Z`Og1p~ zq=Egoz&Q9o`z20wKqg!1BtCImx<|b`$t~q)df!xf?BucZ%rob7w+K5Bl6N8f<3IjO z`pZB2yY#D{zpsu;(yp6h>G%KWC+YWo|0ljrgES-e(Dlx*-_(A2 z{oN)&U$!EH#ObjWk4r`QkFge^Q9U!Mdr!tD#?rVhn4i>y0(U7)j8CLdrK_QP6PyF0 z89l&w&*pq@B?owNz0-Ka)}dQ*-RalXGIpmi>|%IixUGzA2YcT;7Ir5SwkwX?Ny=u} zdZBb--GWi22phJ1Y(~2+mpTmuEZ_YK8Sq=d7Mv!zgCgoiF!1C^OB{0afTIg-gsl75 z?cMPH|LnbK&n!E$C3Ma{FIVsx)WD}kG>t$4f%yM_3#5DdzWWd>!pjav+D_+Gl~qsX z-cO|+!-~OS=Trme3!i1R#aHRzhgSSXIqYqLktGOiJ?SVnbIGGFWK`Fcl;#2cryO99 z1qk1*$hJT60S}wvY{(mZz#a!zf5RL!+Q$hx+uVcS+aRO9bOdyKh37);cYH(om`8DhfR25E|L?!*?_>by|N4Lak3atB|K&f*Ji>39q&LNijsIHw`_KQ|fBNIU z{M&y~%sN`2`S}cQ`GBUD?Dr6hRY;!9uqqwDBeWV=rHm;t+Hc;9*phV0q>3NSa7Qfcy zynobXJiRBq5flA0w}{`ujyBPU7d)s`I?RO-b$n7EGKz!o6J_YTp{@N_^RZ_Xr%&d#Z+ zL^MjAj`}Ofx#pZv@2(@SG=0!OK%ajrsyo>2xwQm}b8{*2`;I}~oY4|f;O_`m#1y>9vQP$N%X+{_)?) zrvY#;066-!(}MJu{J=SWN5q!Z+DF*0-ykH`+=D^z*EgP5sA(1VaTI-g@?UzD3+#wp z@f(}zYV&!yX$F4Qg9WKe6esG_iqzT|8~w_O%(o(DAuB%>bsk5lcYKGNNM;g%4d3=; zcpTE~=5x6r4;kM0rHrSwFd!G{>P_^OfN8HfGCMJ4TQ-a?%rvJ}QrccNK;|xEC<{ks zX~X*qS;~Xy+qG8Rd&R;+U$Do=sC-}B%uUA`knKFUJUjvoYqG1DjO+7KN89hUbm*to z0*!QQmjjrWLz|eCXFtmA?a$&-4!J^`ecGje+DE;oafou3>yq(Gk6>@x7r&Y=2s_3T z+>~}efAlgWWxLIVS98h^NDTLQmXh*bbs3CD=Vn=X$XAvljRkbbrwwf z`iX)X;gv~#cT&D!2(gCc^|TE88~}(&YREY9qwG1=Oetf!uj&w@_PqyGhOh?rDVF*p`!W~UTNilEIxsQ zPcrte|1*YV1t#jgm`-x-y)wP2Su6~9SRZ`*QNFo{z1 z8Nc2blTSj}!xi{p_RG3z_t79v9seMP$j|;B8Ti--3oN`|8d-d8z7;1XSNT3_(ClfS z*Y#~+#eN&VP$BPi=r?F=QyTv0Enk^boYr2Ka;^QWTu;~g(!|1Akeu?ez+)eKwmltf zVtPL4*t{Or6ZT6MOzz584J|EZv;#kM;Z5}DJNxvPdO22kf{gFVbBVLqC@6c%fVp>JnEqk7`k zBSl=qbVWSnf<)HdOTJ1VMzXK3HGm@plJ#twfh+3a?9^`Yu_fE0o$cd;_hjJ7z&RPP z;}o#ug!D$6URQ`u*!MP`H-JA7>#0BVCj*BwaMgL-~RRA0kZ@Fy&gP`2bf%#MrhI<@)za5{B$FY#+CHhEHK& zv;5ea%C<6|mygK6M?P3ULCUr8^o9jB{;4uR@IUXZ-ISfSRs9dUweJ>u>k|Ntj@7-Z^er?-%}&L@=^{pE*S%zc`Io} zyv&EkZ_zyeNIUx;0?u>?-l7#Yd`~^WLd30&7~K2VNP}LcFR+{)Z(t$kn9db%!%5`k z8b1q$#$G)S! zwWjw@KIV0Ay?m!%ue6OZ*4lfe(G9k?)ZhpfQJ#f0vh(nMW(M?#kWa*>PZxe>vR0$U z0Yoz@lDI~K0*@%zvkkgPxV#(X+*i9kk7H`>o?~$Oj8s zMoWv+&r4Cp#InECmM49MW)qnGg)Po+;N;0}Qe;J4oxEaGCxr;&n7O`vsYc@Y#=P`;pSt>zKF(h^oB4)W}G`i;Q@0P3dU4}6>De$XEk^; z=5r9uBfCSpQNi~^ooTm`SmxcUuD1c?JR9J8H(;w3FJyAMDy(29Ii_P;$*p^QmV}Sz z2&T`4Z?pLE{gN)jtEOI)4p`w|%ca+N(XxCdZ87UG5G$cUWXpI#dTMQg-EF{|Qjs1< zuZ6b>^N4UY7gm6L&dcQr2kOG4t@!n@2N}5eu(Xa(gJ`-e#mNV?uGqtPGO#NHy7oQw zwJUdRC4V$&OL*#*lh?X7pT*xQ$9S>#8|j{QpSC;L=y{au?{bp*bq;nx z{SYNh`)y)9W86Fj>H&RE+Z!xvtEb=R(>f0akmPv`=PUO1n+3&Rv@$A{lX+a5fP_D^ zb6+kP`0WL-AzzlE_dN6~kK|4hZrJ}*9vUI+i02IC$it^ zS~K8#S!pxoKvw@i)9boq7P3*Exhnefu~6>biZR{Y-=qG0{ey+LxcDN~X^DyL{*X}i z7s-{mO@w`1&ggjcn1wpoLOqL(UNzWQ)L5{GE&E@ywU^^SP8xhq8sYTn zmp%|jb08fa@<`_%K13bzu&14o4CD|Ep77HiXxOxmSW|9Fe{9#Zm#@ePeB!1#as-Tv zc}GQjGh^%NW!&wOQN?G=7^9 zM%vbje~XdRWZ@Y93Ml*!L#twpt4P)@%n3c=t;?hqdSNf;-`RwZek|5va;nqKmfdOP zFB3$5_vHh}BP%5PN~Bv(AdT&c8oFy4>>JCwM$`A}r^LC)0Pl2y(YxEJQ}N_Qe#Y*? z+0Q2+l|Q!Dt{T1_rK!iMtNghkZts}2|9E}FARm`b&%k}^BTn+I=Z8@rsRBmt&cQO3 zy*X4%nw@kzpk{S?xZ=n4m)yQj5*8cn{Vn%?AcKoCy*HGkc)0eq?ARju0*L!X_}U=c zkJ5Wma&PLN>C*_H|IQzGhCNuoCl%z=3j(Nbn3W%S{)MLtvi(k9p=9{2T&kG70L$=f zPx^SzSi4e$6aoK$;}edhKDL&>Em}7ZU&Q#j2Mcx+rJfuZeExCl*tbays_Brfaza8s zZlJMXX5?`LgbM`quxFe2u}`|+!yWY*lW}c>4JXrz#j$f!W0IctJ2R_>#bB|qZ5A1e z*#yrNy$_VZp6v?$+O%^Mm&$#YeS#5*fvbA8EGs~lzJkTkfi^Da_PCfnU0t&uFa}hfqBY=n>4&% zM`H9iuM7=0-OIa|@W6R#9TyzDL|Jkl%9$BJ4Q;6f9Lvw#F#L@YIMc*eV(Iy@M20g6 zjvx158SoR-lYy_wfE?s_XDwd^%mnE4KIx=vCbUQ9$-t9=$qewUN!Ekr26~UzR7gbX za6gjebvR_jAHD9vj=n*c=O-c~597&OJ6HJ=Z*CRLfB!G|tp&Rp!2KlX;g2|a{+hm& z^xyye-}AYU(f|14U;p)A(#sn_ z<^Eazlm(HV{arHfbq^MF68r4>d7SCE^uS~n0(Q+wpM|RiCv8Opj8^~Bm`)nbjHo1i zn@X54^`g)aO{ArbrY!IRqfFq!XvI@3pcPT*Yr%jF{Rh?CLEGE#yeR7tuWKV7+%Pad z;hRR-@RKnilWfyx-zRP9GxUsVnUq|}0R(ucKVT?b_UaXUp+W!yzsC!r-7d<|;IU}e zc*uhvr!I(RIKMraZH*6xWbiPoSDoV1kH0vsEy zlojVFD_KwD&Go7HQDkabtg$E;`9-_5qE6u~{qVe2+hg@?dzlJDQmE^EMeKKgLmQ=y zc~SCc6V_p1-blAIZJppfo88;4krwCL>lEXaBV(gJy*xdtivQ9VdvuE(h ze*Y-A9b=&nBX;QhsJuSGJ{kB48HkJgyVeEzA@<<{{u0SNlAGu07|HV#jE--kUu|}S zKtHJfx^?A?xBrqiCZ#vM@mvR2r?`&8ry98N+7@(flxKgm*W3L?btJmr*&Q#C3qYa4 zSNn|xq{wo>{1pxOZ&%$5_=j+l47~Kg0*f3I;nW0NQL+t|g*Osk)vTHEK#xu`T|?UpYQ?!V|t(1R*s4#A>yF> z8y`~>yI2V2>8dXA2ozgN?w@Hga39BB?U=@92B`Qi>ya(YIVD6jy@_!J`D`ri)Vx__0B5 z{n&OPl!38cN_f1;9gTruS*BUWOZ`YG{gK@w)o+=;jSuZF;uH5u$feas^&H%!#(Dsn zZ&L=0R39W^FG@DIJ#lAytIJ(8>p(N(fjKcF*LC9rMsH0Z{SH2E3$s6i8`ZT9`exFt zctBgP&YK1DZSJ%NrWNA{4i?u1hJ*F1xkg~!mi28m6~z0FJ!as>$Um?anqzfQ99!13 zjhSzTk%@0JoIQ9`t&(aD|JwZJ^r3kd8J%)bkqANeXzUKhC=G-~lpv zjjLbl4V^6I;hSwp>Tj+#x$8t)sw5rpujFy5E9ArpB*iMX71(<^#ozbXKiz6O?bBvn z`d|TGSwUcLH#du?D0o}|6w0WDfh-fEmLv~+li}X%<0CZq=uhF-`r4CP*7=qvT|-7l zl@H=OE>lQjvDpJjySM=@(-CawLzjHw(6apy3wyO~>J%=Z$H~;`n&N}4<@B|$>CtDM z9W2}?@v%Hq6LSICjGfw*zUx3P+mW~%VK`~S_46X@ zCMm;1081qGNo`ndVN7Ezm9do$Z38!M*+*fKZ(_A<;kD_oL78Y{Prro)8e`EB$f;v( zz_(27njOXGm2fQBx&gc7qUhK06#5K4Vok!;tev~JU6 z#IUKjBMAgNc^~~d4!?{Ixr%o5+$b6A=JL2RhY=ROO`Jm>HtGNt_8l)aV~!(#OaEPV zxA=Ivte!Wv&&PZ|&djrq;WS(c;WR99@JuWp@15FvW1P{`iaK~6f2UkUU-rf~le=gO z{k?iEEYdK)ufQe#ReQfK!A4oVN(*#czx6k}FH%HAS32_B%(4MT?h)w63hvbC^dQu^EJ26jD@ZDoy-xsO9+v8h|cMnGG#qnOxhZ$dA{$SyF zxa$ZAJO;n6)t87^OgQP>dxR*1K539c8{ZutryJGYnPkF5O zH60lCAOtO0+-;|?=5@jnK=hFc+R{DPMs%8`X&6NZ9O@UTMhp3-vNU5ZnQN~Jn$af) zD=9Jtbm^ClAB_VOd8DBfYs)?{472DEmk`_o13cWKqsRFMK84br{Om{IkGinY^I@cS z1&?{Vi$A>>>1U5my2;g};&n%HdO6_5K5fkIp-+3hO>FXJ@jgi|=MF=`$?{1741CS8 z4P@@f!tbwLJ``Aw%j&$EhBz;%(4&)I_BcYyv0Y+oK3pk+PwV6i%#YE@hF=87d4AS9 zO}rPm|9UeUzNUz8f_c4@^A?-qCF`4CL(i&>d9NPH_hmpv9yKoK@58P2kbkJ~FrN%O z8TgzG_#*(kURe)b7}&;p^e^=6F*VA7o>j!M8Y!&szU{o^!yhbQfv88fI=QS_hi^Qw zHWXGb$VnORQ8cZk%oh{e+R6JBsafDT;fARd)VG5NG*eP`F2 zs-;cH@UL|em+dBuW;_MXd|cQ&fq-D$2A?$tX^&*+NMWC1f;igT>yO8zPi3eKG$5-I z>EVxM6q-f{o*Y5!hi7C2Ql6owzqx?DN+*!8Benn}yhNF-!4e~4k@3d3qv1Bt=oesp z--*>tKqMIAagRF7SphbE2LcR8A3MLnCFncY;wPA41Eq=uc7jaqW@9$ypwrhZ;~ItM z?9#D@FHUuLllh+ksN$Yc*eO$u<4z2!#rR@h<>COM?}mU_ao@y>LKFQ_UX4eP38yG} z(mt2)057I*Aevcz)?oJ84+sl;4_HRMN8_xzI3=2V6)~tC&ABBWuU8Mj9{e~*w&z$r zxu*$*bvyQ*lHUhpH3Mg^=c~pZfzvY(=iccC27oiipMm-* zkCU?*fL`NBrVq{<^aZRPhZf|A@FO$uRKqt8JJ*3vTlHK1wrH&}W6b2u>QUbB4GReN zM$UOGeFM>yxZRKRR-g9$GLY}<4P5)^2Mf5rH#*tHNhJ987bn>uD{HKg(V(k0N-?R# ze4bmlc`ASKNN->w5M`!|Z1jbzW)fXtg@7Bh3Z?{m0mba7aONmBdO~JfZyY86LGw2S z*rx@^_OO$~#jXmPX*99rj5~t2XHp_Ze?&oZB3418&sk0ygd%P0KF641_cqiIF#sCr zZ0EgusDMu@V56+I(NA9LSG))wA0FU?X?lZQ#}Lv<7dF$_>ZccsRlP>08_jit4Hg zP+AfI<89Fg^@kl%vwrR45^W#F=Qp=?KBRGIz*3IBH$*BD}~tD zDO%mNKu|+mNa#FhEYD$>7>1(@g7nux1Kf{bCfvW zERA0t(%if!j1#T)M)atOlh+tYx1NtwN7|!4UZj6==rdlPRgHcw%-Efk;c+R-kLFvH z-PLz%m(EvC;9X$vRl-b#`_-FW-s{q19U0j5$pJl@K>1(}&zku=+<2qu9o~Mw3f2a! z5$XE!`vZPwgnr&0J>Z{-^}d|cht^h3gf>;1`rSw_5Ms&Rr^-nzn@Lbrt&Jw$C_8)J zuJdTso(QOor3YtFVE#h*+QpX|t4*KQlNfqeG&AesM?Y9NEr}CA{z73bv5}9jXTdsP z4+T)*fBu25IRH)nlKpg_+Ili^bzm{T&=o5FunC&HSPxjOH3mMYA0iqzd|#^n^?2}O zd<`|l^RfB1w?RKM4ZbdjnVTCBf!$cp^v!tfcr!@VFb$A2Px2bEy?BfVebRI_?i$ z!n#a;AZp}HH{tnt#b)x?@m}4G zIT_javN;B?Fvx}1^(w#tk9fZAe! zkoo6)X$K&+jJbwcx9g@-8};>6nPY$c{v%$-o#V#^xsd`BkMr7oj-ThEjO*rHTNN7C z%Zgx~E9OYU%qpqpM~}V=*bz7zuVdykGW^}Vg|W6%$8*=g%|1N}ug`$2-L~OiWS%gM zX#fB~07*naRBsl0wG;YPW4-H~S3C1!d!M$}yf{5-E*=ket#|Kmd}tGEHs>A$vG(qr ze`jLBmvgo_PJh-<4V)3g-a7X9{7@Xf-O2WNpKgaFA!5zuS&Zbhact-AT#Q3Z*=(i7 zxwjUpT0-jTK#fd-|@H#2*z2czSSzEQ`q%@#VSOaHAnLtEI_e+aL{sO^PswIjr09E$`cah2N;666b}fzTFu9uIOY`DD7IDK3c}be4f2@64Ato_Z1kq;)(@M~}Gx(Rv{c`tm1qQj$s2*H;@`vtM(qC%VwD z1&pSsz=w$<=Vg7H=%2JlcH-6>-zb-oyda-JX!#In=+EZArMtyX*wI``y1dPv540D` zYKzJJvN~_X+vU(^%oa;$iYU={?z2*AFb|Q=zRwS7XY70aw6LQOYd*Iyegf?;DTKA( zYw&qzUR$)S`SKVoU99SVB?juXRkJO{wc=|48o6@#y|2aSS%+4CnR-DoATjhs;-D z8bj=a>^Z9$6&-cS33l%VIvG21nI$hDu_~3&Ih@OY_eovGzPSK;K!v}4J}Jl(ebqeS zJpM$?`$Dy5@MlJjI`7rEUpK~MkruX&5V8d{>|~S>tSjuR2~I}R z%~-&ce<(^nnBERlh=o1>a5hDHg3UWm=gk<Nm4%E3UN$|__)h{t}OnVMyf`}>~7UJ&GKq_DE&LPj>ftgTU_v06_x^34YR1Y zUy5H!iTQ#XcKk{OMC-Ym8^$q(>Ub7038DL-Uh(eQ`9z;D&LvjHv?NbkYSl$G7p9RW z1ZAFL9b`3|##WCf>^ve<8Nd{JSN40y-YLf~5?Z}RxXA7-EUyo|yv|>nRC-QvB(G)O zTV<5BAuYCcBJIt;r2UGezQq2LkUqnROFXX~7mjaQKp?zHp*AP}*bO;iI0uE~dbM7w zN34e4=%yd%M4EDn?B%B)F0`CwSS^3Qkao*Cr{mHZoYnxmaKKP-CtT;WqPQQ;=XGW* zusY%18Mo3R<#qeo-+qnPa!dbw3@zEhM+<)@McH(ZLB&X0TFI0GmywJ|WMWi;MI>jR zav6EqXh1B5-b?9^+qz$^^R#uX`%0|k;=3O#NU`b^kh7ym15Q#bxCHFNMd>$ zs>U;obn;DeDknVj(dJ+FE74;y#(tI$SO9KeX8G8lV~MW*HDrkS>zDjmfckAhDjzVi zI_NItphJF?&#VhoKVBPk#Y}bFMaIoGd@$_V7dUExXiUjorX~F(BHVf-R-j7PZl2hU zbc>%dw4?n-t|-Sn<-4^&LAUSa(1~a>%7I9GUY0cM*>200e1czm7CO$hE!7#ig{3xD zJ|aw99aDbVPf!7mEa#p^3%C* z2F{uY5+Q5k?AizJpYoAwy8$B$&h(v_d!?QEnepoE9MY!c@`Od3fG^q~$;T1h)$mT7 zKdZL(2;gueCzd!iRhv5iurBxJN%eJ3wv+Mdno}=ad{x{hcU~Ra$?l&6hg_q)P-`oB z*L*=Zl;b&$+#7|tvCD)Xn1GXp=Pq-u_^~{n9)-GhogHEJc(BE~>&g6BI?gFIcsV-0 z^_`~EyBj4=eB7i>Tn88ZR=w-_ zNEP20`BS)YuC-$C#o~3h==Jd4O=mo=Ih%YWd+v5);|3w&K`BV(n%h70v5!`N4#U&fC9z0B~cAz5Q&z3VO~tNQZ-Sk3f0 zVqRsJq|Y_$phepXj<0OO(Gpv^Y_a~gz50Bu#)bpgh7H&pmknwkCwg^^o5tv!+0%Km ztdYa{k#(!PdEP4)ovtGfTys!nL!5{CWMFp&>|TKVX4!6# zmnvdC|CnRaOEYuS7#1L$J>#r%c^o~0Zs%f05SF_1cELH<^#yz(AWzStUGlNL<>#?p z9e!ClFMhDFHCx9EmWwaB;u&NfO-`ebr0#ld&897LR3DV-!r#`A1p5$IYVZ z)&%^DF$s+rwg-X*fPQbYg|Ftf3cbdLzlNxLX_F5s+V#Y#+-JjCaZBsro8XDt+04WH~aKrUwW{Q2Ndqk32wSGpDph*W{a6DeNh)>>8`5SA;=oh2tXp^) zS0rq_%s1kIrvKe|Zo+@m6j4 z9XHl>q+i*`dvGj&$eI12uXJ^hbV>?W9My-%kcZPrZ^qAF9roxg%ssW!!ilskK1AKu z^_g!B&a#Hi^aGY?0{8l8P3dPEa#*b~P?{KT%^9cKY{F-KNGy$(-}a^oM%ILQh{&PL z^Q-3FyApi2cu=3M8byBWEer3;RNL-1)D>?sa#&bYGAM26q<^PfK0b1Oy zSP$IoqhcH}M<(N53~b zSQr;i%|%a6I`k%0&8eKKI>@Za`qxTCI=#sf<*c7he4GnBP=KQ!A;!s(`YG7%1d6mK zuHdOYR-v>dOZkFMISJ2WX&d^^E^fGT^Rk5o-`KOgA54$MHgWvk$5*4dUh>URv>9fJ^-=FS8U}#kt_2BpX`yAO6UufFe#*M zI~hmhRSl^zX#ovwU?sfVPPFg|ACs%?0R@|)!Ui1MxKSgY;FyaG6kzwpjD9SX0)hVZ zaJ+bBZ%14ntKCNAmNWIox;cmDqfZ7i)kf=5Jw#V%g`tJC$3AqD-r4~7Tu^m+K^z)O zJD|*lJp2`5cL!`K`epCS6gyhR3PC+aPn9XOGer>S$uc!PLLk;BgFQYe5)2InVJ zKD9y<44eYxQ!XcC@aeTB0ND^%_M^C`h%p_`%%BNy+Am^8Dg8UOj{6o8+6oTyNyT7o zLcWCo&DOC^Q$8ETnr(-(Cc5x3C`cE-xrxo8F0{@dRM&Goz_+exJ_E=T`lNAB$Jyte zbnHRHpXJ&l>@{RxB#aI%%3FI$*VIG+6F%X!*wdD?vwD8xWL-1()Zl>B81IH;m8Afj zy7V8{^EL;|*wq?pb}_%9RLkWap)=vU#)Z)5Ir*VTz2o=h2MhQi)#fC~#Vdzr`RWpr z!H||e9Jrghn$*I>#Wnu5wtouIYI9kk@kWRhzoF1B_kf2DK3srQ=D~(Pa0OS?PSQ7Y zD5n)dx?OmIw>zJmPWT#v0M{>gX(z3-^4Zvc>*BFt` zebMzkBmWWy9S|YNW3&6}sV$vf@(=N9%#iQ+MSTU4mz*Q{8l}ZMj3~5oU)kbEmRVD} z2XLt|%Hy$sZfS@3aZYd1AwTkmU3ju?N{T2=d&mY%`xAQlDkREfGa>^U$e?a=1@I?q z;!g>QE^Ydlapi%+ufKZZY2!y(+SBp0fi-*XmG~g3oZDv{C$B!Ld&NhuOHq$Y+U$Nt zm%h^WjLwXGk4yO;c9FF)r~K@`=+A1jXJfQm+%Q(K|)%!GNv-3yf z{_3tJ1-bdIw89>?P;+`Do$zTc#-X`g%y-$Hake#&XY{g+OgfAKx5Ib@diJ|B(7Qog z#>Pf$tuE)+h25^6uDBM{7K55498?-xwB!3F&$S}yUbdEK_i&bPhsvcdHr7I~&dD5( z9DIE}c-x-tG!{+z9T}Dy^Jx|H9=}5tf>)qUm#+8QIC6&d<+H{{jE`3^FSo> z{iIqNJ-ktN%NFgo@&&KG=QH>bcckNOZqaLD9@TGQUQL6~Ex$KBSinI5LxjQKI#d=O zP6Y73

    y9kio+ZXj-sE!}ReAz8}oOnQKuWH;MoGx7@garDV+fD)GUN(Q52S%l<&M ztdZpgL4|KI0E2%(_ib+tbn`G)ls8hrU1CB9PTJ<{WJEkSJj`GKwrmS3^1>-<$dFWAo}2Wr1^c1 zPdeUxkM|DazuiDWV;nDaqa7PRNvf#p?-w`$#?#VuV#B8C!Z*|uME&0C?3XYgr?Mg= zEzK+9Q>A#8r9}WH*b1s`ebK9ggyr~N4sD2)_|Pu%!hFP9B7Y7amJFx(i@wLEBT&n{ z;38#a_ATt59^>ipXXzuG;GJb>)}L*1s0*tNjHCT+L-5kG@ggU!yeS=iq#EeSd7Edp z=@aVRK6PMqjU3hESogs*K65$ts>T}2KHR%!K*k;?O^cyl5W0b|?z#d$gmGCO+;QoL zq%wzjSx&Qsr@;(hU4Qb;Y&`pSWZPUML1Zv zA!>9y88G2FJEpC!z?QSa8&b-X7vS*^G`6`{x#Tb3wa61QTfq7k3cxT-1^$EYuBQ)l z&Eo>oFO0y5eZx$-5fJrNb5+`ASo*;jGTX-VNiu&^*Z{?U)vIhLY_*?aRlA)R$k`1z z!$Mr}5C3$;VI#G%k6W~(n#rzVM~!|T@;R?5Hq8yb;>Yfica`B;&mL}^>yJ;#KL36J z;!J+lpdsfqGxRJUhNB*2Owjxv9xNbzw3#%GCaCy8g?}W8QTRK*Gdr5alc$0YF5t5b zH6(a2lEaY>VwOH0FcJzOg7xdx}*f(yzLn{w~pAE~-tT-!1AT&91e^ZqpbU=MaYSZHSp zuge|d9UTw+fAP4x54Q+jw zJA6*Df==_&Lf0^)DQL-O6C$SVjRyH@lZ3z=WNZ--y9#UAlm(xgwo|{R7hjTVRH^rX zN-?U}(q}z!Un^KH5T9;&Clu6!Z1BVTqD0DDr~3*$*yW~nvJG<;T;6=AT;9VrI`Wd# zG7GS{Ne>1;v5-=yb)%6L(^)Sz=pJ=x$zq08i{JS25JMXJy9csoZoF@7t7&mhf2UbG zzj>uC1`S*4qw)`U_Vq;YQE_0y)iQ{$sovw zw(QSN6kjhpAy(BYA0-D@Y#z2A=X0%9v9zEu#zK#Ijs2GD>uH=2+{5wO3!h7i0RrjI z$+~6@kb3uwzv=3|iED{I#rwVyKYM+J7Yee9(XSD_V~pa#Kf^cJ+gh4ZyN7eU?<{e^>K2ZhEV*m>t$RKmgzlRCfydXCNQHegTVo563v1gf_Ubbt z&-ZP7=jGokxh-2WzV1al<6LdVzei?U2L6%HetFYxn|?#O95nQTYD2~N#glD*FF#<` zPSi&?n3RW;>$HT}iM8Cslv}!dxA(8#@*UT57gYV$6LDM=_5$GIQutisO~25YB-rF1 zXlTQ>>Lq3MOHTar*SO#!KzuMvUZl3M)sC=eL-&X{*;S0BiW||mL991u)7KN3pEb7X z;>NByoUxck|HIR!Z$3f~w}AB>_0rNlX#TDk706F}|88<8GTp?DXny4qlwcdNbDYt( zuNHBTb=Wyh>T4bZhkI&jT0|2<1YYLL$AbQrkC+O@ z{$-x^XZ$DDjO-)x>I}#@jy^)nX|mHTzh0bK2f*^!Blot*j&SbCV-C*D?H%K!PmGuS zPul;KF*XTXLqK#$A#7B(|>OxI(}w+p|3xSY$v}3Ear(p0gzW zLN5_oC&T)-yrN(=D1BU9e$mEb)2FSmIv!s(7N1^LcN!C`=kJ=UpI#qF{onkF1w9_D zTrj?~XqSsFJhq!x;OXfvj$LV?%Ring2Rr=xjqh%O3f@AlXGa<_I@P9Fl84q7d9>yC zXsZjDPujMds%jE_frdXBE17~64=}WCQ&L{>-(jgeCBv5eQCGj~A3vmmvD9744ME|S z0=j*~R@cqCQ`07E0QtqHd6&In=AN?j#ZIu{qtVp&#HR0zuV`7kzHdWLzZ~-RcNWvx_aA2c-?nzT-d_j&9jk%8Ua0^)2U>7&RbKpu%FCs!Y?mb($P*&C7 z>B)pz7rgjM7w=1rQEYg+*#kZvzL~F}p>D9(e<=p-r?$t)?J7QC8D8e6MnbiiVY*>Q z^VL=lZ~%a`Y^s#-oX=Wwu;Kn7u6@)G+W0qRPsQKz0v|gmReul6{8Fn)=#5h>}>onrj)~;ZA7VkYe-uUX6Q9Huh$fKh%e1R zUG2%V4f*AMa4v4CiByePDG&MQZQ7By180j_hq+E>jO&u;N<_PB*&^TP)Vj&<~Oc8I9KwZpf-xQO%R9EGNHwThk?`LdlA97;f zZ?O*^L<)4t8)fMeWb?xX#HIe>MIFj*5)YRqxc$#hxmdo1nrhJ7iV&l|`HKiF4(7n`p|5fcpiD;`__{HT$MAG+ zA#m9!hbYXJ*yD|T(IMYt@hTgzMjO}(A34MZ9Aar(K5@~cCv5d}#f$<>zNkA=oUG5{ zU;$vQ6v{+v!bgNeyNI23e0##uA(3OY%3vPDITXTm%BTxPM}06>nnU0I@Q?s}{Sz8F zSu*jgPfT)A11TOsynKRR^=Z0zePZM0hMMm^LN+!3d1zpdUdU$<_2CcIT| zMjmruk54n$vohN=GK>7kZ5rR3Pp#HyZi+7ecwM%!Z*DCzWT)q|$hzyGri~jB-?b*R z(wx(h@_lr@(6L^u&e=-cfH4f6t?D(;_mZ3^vE8({RtwX0MJvnWP2oJPtr-~BA;E5N z?4&Eh{f=51;yujMGO+0oa+*~SRIjG$k7qw^%>d62n(5HB+~;v>^=Lo=!{anWE+pBx z&)9dX*Z28&VfdFp?|!gwhDXD~Z5ZI!B3Uss1O}vhGfL`1Z-O-HL~pg@o!??8e}{Xj zxqQ;ezS?Sl2;FY96lY9I4QJcH(|_2zgJ9K86OK1-xHn|!i7%8VgyGuPD;W>?#TU4b zE_BgATOOxWmV{(m(!;L$?tAAm?NcT0*T&fEvyHuP12guc9p8tn)`-Se-$aF+_esY* zA6YD5Zy)l$Pd`A%7W+oESLEH&y!64#q0Mng1EFk>?c233d=_ndvOspM5j=?EJTZFG z%7$E@fEK>?P@DQ6pK6f}ecbGaALa}8${OatB*ZW1X$P-p8$S8A2esVn2d&t1lF6>4 ztTvfHzxQnbs4YIwNc~mdEdo+!I`4FkfjEC-Of6*aSQDP#qk9=YXFUBiZJ(=DMU7Zy z;-J4o{AWL( z0eu4ja%t(DS3FVD*TxoD)ywOA(FFZ&b-@^4?U&{Psd4kCWNLq_%*Oo~-=xX${ic|F zER1hY{b~)S`Xi3Ve%klR01gA)Pc(0FQx7R~2Nl$m9?wVOSm$y6A!~bYi@axz>SgM4 z=r-E-ej~}AHSu#s;2S(e6}@*g@N1KK@?L-LI;zOi)O^Cl=pu#vV*}%z!lv-qdp&lo z`&cf<76G2^(bwR;^}zzNSWWPBvKzB&=Pok^5H;(*o-iqlOcvZgL|Yyr;74AIA9AZ% zc>0w{!e-&4q#(||G^;lH+zJ*cMHc${FU8u!Bi&Ez1+M%yf-iMIW^C9|Tg3;oauyN3 zba2D>FFTD?p8du|twUcF4@S}!`=rOcj{&^c2d#xgyWnGseWSKtA=Aq#-`Mq_ci+*@=W-GV|kQU!NPA@l;F_TKY3)N<1JX~u8ucqMtaGA z+5^7F_VyqT9{*}t&?(*D^Y<-aSRWp=bkb9BZ>P2;Js5*7;?-^$=hz1h;=`VsV#9~@ zkffqt@K6C>`W(}q->>n#?5OSI9La&l+)%F7C-0N@quj=reVkiz>Gh7b;jfjp)wkhk z71hv?!#~|yjhSwurQgjV$|w&29_6AoJnq5HyK*=3Y%Ttg?iSu%V&|vT=(|0zU~joc zE!n|h$@^x?tLoxPJN7xhZyu$4#1_Y}u_|Xrx$cs1mvw>u#8~d;AN}0BFK2vv5gxs7 z%K+YBg}-O}-VEGf9edWE^Xd!7sr_SWZ&IW;<(OxSK(!W}IB3O=U02cm+TQCyc7NP! zmagDS0`>ll$Hs#D+v1R>USoLn!M5eAEaf&y34%$Ff7s%LA)P+?1%mCkhb(E-TiaVKCZ(RJPliJ*?lYDJ zo4e4%)Wja&4yA1iOL}Dk4*y79V3QH{kYm2VTGOlSh%Ygp0lwQcyI|YmgM7)zrBRbM zav|HupUBgGBR>k%cu^l<-%R2Am+T!#$u^wPx}-qDmW&yij%lL}E@Z(%U};mV+{Z#| zx*Ds9F?#h&eDLx4Ht`^@|KLZ$#hz_NEas_J?Q5S#!r&Eb+TdP6;ll{RS@w2`fj$0V zBh9y!JBhC#jTgKZIITJq=9{CJ#?{ve)_fmhT7Y`k(Vjq;-BZ?gxH5zr*B zmERVv1t-(@<2J^2m+}6%1a5HnPV;Jm+mGmtGobT#wD9Qp|Fr>d4Oz3I=kf>7*Vk-3 z{1;?EZ>~tu4ad^)7dJ82ePm5N$EEdLyB@CY*(o_6C z9q<&d9*{l1vB0rBd7i&wv4;&FTrl`JDd9r`(4!Br&$TVe@<_`hE6G8j$nY#8G+4#v+!a3_aT5URiV?$>m+D6E91;dvkH4y-25zl@B76<#+globnx`st%vju)NBD^)Wf(;j>NhJQF^F4rSeBk3b0~ zCu&yR*^)?EJ1LZyNC2Y!cNBXH?DIjtxX;3k24zZ}(ch-9xM*MF1uk`C8&QDg#|kpo zik-F$F?F9@L7QIXbQ;|0ujL^mEK1J}3FB!QC6E5MWM*j4C)SL7TZY+cb7!dD58s~A zy;_Dj&ihmy?6>T5>a}l)z z>i%e*PQ~MkXiA}S5uI19m*(9*9~Vx0!dV68F(=yR=dG6;jxqO;V|(dnz zXiMmaazX~;_cnc2Ayk?mY%+w9kGeBOTCNQ)3~| zI3-5%5G{4}Bu0Addpl@Tr=p8+lU@lSuaFDGs*8LZ0eJeCzLusE+u5Oyn#(lCfTAtt z*u#_OX}jCVlh3S`nZhA%%g-`F^qY2LpLE+NeFcL(*%aH1Rj(lj9w*xHec$#vYi+NG zuR+3(g(K&lZWtCrq{lw(c{M8nSf-EH z7OimCTO)nXo6X6(MUhDHK(;m4r!X!Q8pX{#VBGNk)|<<-j|o1bzsG&(F$eFAy*d`Z zz>#Lmjrq9diVR8m1)?>{IeycxIn}1r*?3JOZzdwfI3LzrIx{al`}Aqn7Ukjltj=1T z@8DxjymGtT9?nfK2uR2KEbI;If3B^G8`Qogr&FMZ(*Ds1xx6N$V-FEaj(sfJ=T5{X zM*8|EaGDP`)UPEtzjAoVS*J9^NOg8Nj}7y?YE5u9`J(gf{P97xpYppajl8UpeBL(Y zq6^&0Pk%mp82W-5mD!eAMZ2`{ftVtZMKPdlx)ZsoN&gLB{0I($e@>2%MNs>hu*A34 z*7mn83;iS4LdU=QXqj{MsS{f(-ZN=F&+R-Ox8`jeM>7PJt$yIl0d9J*zj+YgoOE6^N}4j70d5q7@6(G)G=uYZPHLLSZhmGQVGc#>bGv9G;l0K80EwVFskId(1K#xvx z-tg4M=a1zR!IOcjGr;4|cdi$_&Wu@XUw1~Snf`a?Z6@+Z=G!wc_h3P9Sj5{+%hcEf z|MF&!N_NOgl-E!&skty_i@145KM&mla5x#fZd8kk*z9H3`&jAJaLXQI@l@Ez!5IS~ zQbzCTuEd=68(7|y`V5(bu5}^US>Fq%cIK^MH&mxd2wzHBH}wsD>B+9zEa#3YO~z6Q zrcU-fFk5t=n_{PbGT*`=jd_Jx^)`$z=!myL2CLeLN#a_ca@a>%@<_KhiyZtWkFo{F z<=rz=8(cmMi)@Z*q*wg}KN`&7O?*R`w>J5c z$6#&l+~*>j>YUE`lRc^49G~xS6eSOU9g`4TI9tMcs%;leZaE_Uh8lHNGfzROf< z66=l&vAJ)anV+};3dbAXG7yH?5Gkzs2ACF$lh;d3*QXx#;LV?E7R@vinb0aw(1^{; zT=uhKYIq-8SYBRnaY3A7(vPYQ&i=Mwdpn2P^i4qWQ09hs1U_YAG7lJD?N5V9qbV=sjCbZO8AcP&i!b^#X$Y(eag#7 ze#YOC{wAIuSl><{4bl{Rj3M?tNX)b6p$*-h%m{ah6Digo;d_l)xuS!??ib4f4-+9I zobgeX{bFO;(zPm)k1Y$ucjlk=tuOqvW};6Swy{oQ?z5jgMT|MlVk7figK<03(|mgd zY!O?I+>%09Czd^m9^Uu8I1K$`05D!NW2SP0_4!D`xYq2!IHJ49-lOwm;HP9@%lbOA zhA?kaviZD$S|{ug(3J5LY&9VC6l$0KH#^>ljq17W*qxT#~bweJ?hO`!UQl;Ad?;fMD@(N(k|9Nii88DC4J zvN>UEgyr!mr}0eSLYF!o=j=9Z_&k#aTcGRM%Zt^(*aNRSmMmQ8m>t;*d1iE*HMYoW zK_b4hD$P`|g;=#_VwsTvDCKO9)Qh9ehmc&2PuH6gL*?9Lhju5$|M(UBnALli9OKjV z~0sV;06(`<1oebCq*|VVYcp zXXG-j3p?tWxxvNa2A`anDh|slxUOcsuxm&E9BaDiF&H0|H6J_UnKh0duWderX#mVU zR9GIE{hHB$-9|x3>Kh$A5N&Twwi9mz zKHxE)Sw9C*P1Y7%`a&RyEY}ce->}EDU|wR(%lM?aTOTQ23MNYLgru!NKiXWQdVfc9 zYrJS{J@z%khyP^YXJr6$=InKG%e<^)_4EO&Lu=C`cZ>1RZk&PNIJwxcp&rxeSz9aN z{VC_>c~gaG+_!pzqc7rDvh`ieOV%fFyTE;m=mi{InJMY_Dj@~!B z_3Ln3?nrT+VJjTa>_^XOFpF<$6db78 zyPH@annw>G9nSsSXxJvt1En=7kt{ykjBlt{Bw|&>eh1=ua4f)9ht{`tR4=@$Oq}UO zUSLLH>{x|%lqu`)k{3NdU-jL-CDW#vCxXys2P(&!Wb2E!G8k-wm-|AO&m>4%^_79^ z*r^O;DUa6rpaT9@o5t+C%p%K+U*sjdAs7BP3NwHeD(MxM9T^3g-} zJ*2P1Q|qQl7Kq2Yh@JE8INCbGGFR(5kA!7_bsFpF$(AP; zeU0CBSL9lsEQy8c~DbA z3>m=kKX+j5yEaW3VwSU(vlf=KaFA*B??w4X4X~!}ILc?{bx(XP`)3YK3xAKFM|I)_ z@lm`=U+0=o(mGS|7M*pW(Md1z$&-c!bSBUtA-HB{gPh!GVkMrJN89^Feaf_X%elAI zBSL#0<=aw@s>M65;3cN>vlF{o2t0IkZP7+zIvK4wpJ2yx9vk%1sVFk++wpKHqD=1& z2(~uLrI;{sJHQ~Dc(j0jsHY;UjAefiXMny}cU(5vSIblG4jScrWgxVR#c{-KzC4-t zAWg;`p{*u7)o$|q1$R7I{KG%44>5N%&v+`;i#zazeqCv5nX464^gq0Pujc`0T5kuchPQImH_M8T?XB+tIX2aNyV)=N_Hy?oDe{4;K(l z(7ft6bro)K|MguKRZL{^Ny z4SnPWe+^8q4{w5fkMtjkM9=$>dggf&4SJi-StfBI#*w26MzH%}fkLP5Vetb7cV^I* zgS32jz$vn^22M@+8rV#%Jq+kB^U#xCP6n@g#E-tk_i=jh_?X}4-S=AN*C6R)_vVRb z=53DXT){z8{7;H*~yy%WfZPxvsTjnRoK4k42Lxd-`$qJm}?( zrdxdaLK(mJJm2;*y*6U|vvtzdQD830u&0v%DPrY;A%AG|$J(`>XyC1Fw6P}l@b_f^ zBY+&ouUGT6Gcip2j8|GRU#mMLWa7R0?UT!`UwuKuXa93E!1ct}_s`A7Ya?gwVb!-= zeR>wM)IuA7 z`g4QE1G|YzJ@EHzv}L*&gqag*{XK*kbGT!uzMC-aA;IFEw;2DZFd;kQBU;|Goy{$B z6LF(Ych29MZ>?CyIb1_be5oy&ee3nR;YQ}9uoX>p6wzx>$uRm-3ZO8v_d^dL(JRPn;n-4$^ z-g&&yMrY3f-Q%oS-|cG~m)_O1lb)z77HV=T>jAf=G|Iia#aboDg{*R#XSCk{$%AI0HV*!BQuvBUw0-lEAu%zI|iA7c1sS zj~DZRy0rzN8o4M|`qvg)u`|;exU6;4s+kro*aEE9vMY8yH%;^)p1aJsQ>*7gmMjhU zN$f%`t(mc88r0`Jo(V&U^b3?3JX*K4mObui4Pd0L>@~(#O$xvSMsFS0Sp15F1WazN zJd}9h)ELZ<8QOJ@y%TL5vt>NrHqCYW-a@l2(30_VD1;r{PL~G@`N4oaAmBU6?UxnG zLj%U66jhg&>Y9wVNmL9<=`a6OTE= zChHiMGOZM8SPj-P;Nj128nPxNa=g@Bdx=meIp>nQ+@6D7mjY%QyJB&yR_1n*GryJy z`dy(#>{hL%w{7=k<=Wa0^_>~;>*qVh@$4Ckcv|xhImR5^ALD+_caHD7ZO5#Q)@QB7 ziR0ujrgZ#_c85AWX639Q;+1Q8#ZDNj?iYZb)gj&69K~q&2xg^+CSS&cxA~H96kPCV zgQ#g{BME5YIxUzC-IV8Y!V~!GU95xK;@Bp=?m=4uR`91|qav=RgyDK3cfhC4#PRqr z-?Gr6sjKNF4!7qWZ<~i$c1j4*qqTnYqLz<2h|=SJjkT0rWgjKYHj<`UEoU~JJl1QD>IEIv zp2v=0kWs!E^}b_#GGoc%w1{pha@&EKK zjW6x&#gF>PYnWnPXf1KfEl3~_gUxk}E$JBfSxi5!+WfSq@2?-805$2YvEpv1a9#<2kGOnQ}3=pBcs2 zYd;U7q|GQSJsl-=;y!lzuy00FTlTXD z2od$`5fK*tlQFSx#WSHcFCwv3`H`{d4S6fJc|$=`z$&|eJ=aa1>bbq4X|AQ&i(wA; zn3Ny0-q^$2kojXqH)?wysoL|Ed?c0Cp}tQJ;G|eKb46n{wh5!HoVC>Uj_V#DVN3I? zCx9()j??z|Raup~xx={%$8WiumkK=`YZdrfr`TPST;=P!{pV_)UmKr!jVSd>ps^^| zB&p?)68g)ri+KP&@}LuG&?DBSql2DnB&XmU9KLX_#rQ`37-NZVq;)N?@&UPOovUqd z!QSi`ioz9*+LQj=S0RaEel#7Be{FCiiX!<{uuh=Gv%zwnJNY9`d?&bKpAUV+tw(o9 z2CPPQD6Xi?;`Zu!1^!|0%mBv4=bYm-n#x$Wyv|P3r>nsy`riUy^LdTm!d#_Evd+d; zJPd*i(kR|l-+};e&%^_KjD4*iTpD)0uQHx70T`lP`VJ?Sv}#Uf;kt zsuSm$M!bz^&o%Nx(U4bPfA35K{fKkx44;VI!W!{f@(=CFz*+`a!!xx78OpEWel*`5 z!)D_^US@JQi`SET^%%sMe#H3qk{{^I&6AUmn5X-Z8PIcAT<1L7S7sodLu060YgeXy z*~QGvu`|bhaRunH0}mDCO?7Kq^ONOkN?*X|@fD%$eZaaU4OSoWV8I*S0uW^l&{kAp z?kO;j6K%BntyR25YinsH)H z-ZH@a`mrVr5}x=^&^8x!gZfSe^ z7T-LE*Xz-}%&6V;M~W4<&%x6_>**`7bhS)8?W2uO4rHrXz=|`H?wY&G8RQh>vekGa?(by{*qX*fHRuR_`bV@mK*9YifShccgsHuK7jTWVrWg+nYx39f8@_i_(U85?xtE}8Eln=Md+O{Z zK|Dr2CIhW{OKbHo229I11C`dAuTfBRn+F1?tLv@n51sMMJvv^0PdnOd#JdKIe6;3s zk3PcVu`0?ma~_2T{g!_a*CV79P3BAdcGNT)=*Gj>8ySygl&6(M8R0vbj7fzT?DO!! z@0L9{=+@|W_!!;FexecTG4rC*#(sqpa}Ut^9_IX|azh7@W8^G=5ZIg5kS zF~T841+q=i*eCtjAIpH{y3X#Fxy`XY=EbA^WZ-i%5LdWYFT3^Zy-^->BBbHY9q1wb z&SGS&?7ciOR#YDL56i%nb#V&zRp*j#8)wYQQ!?|G2y{(J=dEWsaF27N&mNb0Ed0t} zbC$jHYw-@_X;GmzeQhUx8A^kmGa{jDN)72Z7rU(IRCZ*yi?lpojpJ*y6FhAS?Ti|sKsZyCIegM z=2hdc#fNLMP^0>T#B9tOgOsT)?g>xkYU`%<6sD|OT)JxSeol{dVbi~?0i^HC#5sA5 zW9a*6v#)J=TCICjN9LhY>4m`=Tf}n?XFK zhBvFfMPbW0#MnM@{I44yP?+m;&m)@}R;dV`&JA{*6pQl)lJ^wY_a311udU~GT zsvB>AE1t)G+BeBSsI{ATmEB++e@m@#O5Qx}IsdK3-Z9~N!W?*P@;vR;?JIpa1smTd zJn?RC*qYt0npe?>tL!k&XwTmq?$s-OTIw3A@+FsR+1&A9!ERu92iUFyf^l3l2o;06 zVbx$z5f}L2+4p6g`|~rf_2+V5g}ML$KmbWZK~!(?bC9uZm$}Twb$jNC7W6y22mVv; z**_Whqztsyfp_~K=iZDzSOWcn1YoqLD}Mjw+2%7~hn8bEbIzP2 z1+?W4o3<0p2tt$NlSz^fEeKoRn&_y;>I-eH3N{4bNpiKpeGkdUbLM!yCz{t-^*MWu z#cQmL;sEcdhimeF1}ewy8KX0@xgUe$0E8`LLA@+jpHMg;F8Y4zT&Ct7lbWpDV}j$2z{?+6ZbTYeHd@1-1PWTP)x|D=Y;pYl7{ zZN~7oy{SBiZtmuFKtQoz^1`ogbE6F}#4L|^YSHIbSx*Ob&>H4*v3dFv+ zC$u95KfbiuqLDY+&*&i^bw*I=HR|RE3(&`pO(G$`1dS%}?n-OJqzyDaRRB6Tztj~J z2b_?e4i**@?-2&EILO3<;^j=m>lK~YibdgZ7P3Nc<|F%1e`MDKM}IwUwT+Zq6~LVY2k_3Nn7@JvG|>2;6#|kT$hDyZdDsQq1p5c=~&#jV)z{EGsa#c zv&iqoW^?VS9Xqyp8pakm)_>`^hxTtdkYTjSv&Z^R&j4!k8|Gy_$3AmZ zbu85N`E#=)ald=qTfS=!ww_1be7NUF^JanIWaomh&?Df9>(0AsX7=1q&xCGxZS=0Z z-K@%Yx$_CRJMN=YcmQcYmcQe46}Y(RHM_2HzVAwOQtBP6=w9^YNbEgbi+g(%s<&eu zUD}gqHQ@T5?RJln2C!n{grxDe7z;zXvMs2`308OQF>g4$)gaO+Fee~o%geFKtg4~I zCoen7p~BJm=K{d7X!C;wAgV(Ggnhn0C=(q2@VG&5IB2p+OFDA<#8N172_*ZY4Mh|g zZq{EWD#}rQx!1x>nbS3>cM~%Qvcxz_S^D2cgpbDux{v>GzBmKD+TIiLh4Vq(A^wRz zV^zQ(wm-2Rxrvn58Drn()%z8{GH$n%61T^l!Vv{sAE$&53UQDQ!rVzcuJP9Q6$x+o zX`4TVxwZs4vtj<{7rVaEwnuE_>PQ z-DM6vz)uFA3^X!;^`Lzb*ARY4Uz35mj{B?q!jWq8x!YtFT!lMneoE{eKJx6~Jlj`} zVkeCZqQfRxJv%Q@!hZbz(^K;5b!yLm-^0hriLxg^(Ls#7ftNl5w-c$T#k{GF!rtC; zK5kb|8aa)+qU?s5QM}&5nimZA{8d6@J7YW;0$pW)Wp8&CcHA#=xG^h0+x1|H!QGb!pAc{=Dj^*uhYou@#{>UYH8$ui$@v`;`$GDDSxO@<)tGt+L=aP+Ow3E zk7yEs*dV=nQ@8o>F8My=!+U&2=2D`i<$Z|jg6FNp6Lr0O#2LwYz3bY@!LvOX_`D3n zxipI4UGr?@<5@mM8)xB;^7$&>H^(-e!*CouYkcqU^+#*tyXqrPf_Tm`om3|Xj3fFE zicQ9K)>sku*}o(ha>X$deP707_NCs#iN1aM_??x-m>1yA z=BA~4mR}J^ZOJk5tXc*Bp$7}NOvUdnbHnlaPq`WSmwqxq7(&n=Sr*#g`bmcL3@=3h zDrEnbo1z-l$p?J;mG`|HrM-s@S@CH+8F(^qMh38ERJXT!b22S+SQ6}Z>$-JbceEtc zZW6%ae&!el(NbJW5+qBa@J%KGX+Or#e(OYryrO@+MLSnu@Ab~ohF6*IA2Z?M zZyeuI22d9xzULtc>xsY^ay-5ES#6K~j(T~A&9id1hU0U)CVM8IIX%Pv)%AB~+`wY& zBiv7mN#l~2)@t#pSXy>aUo-h0jl~^#oHySXTQA?O@y)V&;@>&2`2*gb13UEh=w;`g z#*=~iEUmRp-Ze+K-NmbrEk5E>{gdL{65AttUIzRzjK{U}&_5e#c+9&vG@ZMh^Cv%M z>W0Y8%yqrLFABA+d=--3_}CK=?tusz9u<^hG~scv>z6Kjz<_t+nuJ+}o}DA$1OgoQ zltTIicaMV|op>Yjd8p7y)I+``15)a!_L=RH`X3nf=j#yDxoQ)3pmhQ(&Pjzd!?U^C zoBQK+cBL}%N+{z!jifo|yD_|k-q`v)faW%Fa5sEJg| zsbWzZv<%(b7zmT)#JydfqIeorT6+hh>_&814YEBx=;L{AFqL1?c(&ATRJvZIfzy@s zdNQu*?H3hw*lDhx4UNfjohvT}A~yQHyv&BhNE<27YmLsiUZD`+)Ak`A&a>$-f__)8 znb;sbixt#*-6P`#-B@QmJ3V@?$(h)DAb~#gYbJljk?#MWW=a5G)& zFeE248x4Pgm)OwWfUpD&m|I<10vg5Ei^x6XDS;!X^ab|>=mzjtkbG)lse+f+VK4Zm z$km1DWb%clfF1VG;~1J^q+*Y}AyCEKuUWx#I&jZ#*IM(I>h|#1UyGjHixH=O$jiw_ zGu%0Ap&UbkzhTME_U_ckYr=|CHjgNT<7Ehztj>Pc4%GS8#cL+)8no0-X%#2*{=W=N zi}#N;0|lmT<=`>m`r!m|B^lB03J0)0TrjgH&xCPbo}5C8j};(`!U1$VkuF{oj6!RL zSh(F*x`J&X_H=ey>cKtqACdu`2h)WnRraz%3r@-8vw8S32SNqFpKEL@vMnybW_!KC zC#v>e`7BJV5;W>Vq06Dh=_6FX*~OlxoZr@UTkLAR177?wm+UxkzQyf`$V1$$EL`%` z>!qC6vlwl@FkZtpJ4Zb+A`y0B8~o8`9F(4adtm zuRPg+W)@4*GrMAAM!J?+=t?>lv|1(iT5^c8bsj+39v`4(BugtBV}ogAf6IQ0?v$*J zbt~SUTO8{ipY$p76uxMWF^jSF{Rd|-Zq7%=b$#E=b08%k+V`@1+Pqlbqq|q?Rz{xW*(0%O2 zTF!MZ^JChQjGVJ>(1T>k{PW|ZlIwt7ORr?WSDjD6C?6W*JeBQA1m`-l+nTID8E#>& z@mo5M)*6b|Ja;P7&1GmO%qdCjBD*pOzQ(2<{DFcP?>c{yxvGY0?yFOT`P8aO_<^g8 z)4Y@7R4m!o@%JN+^8#sG)_7lk3uk$(I~F|gW@C-M#@HkOnm#HaRD&k{8f!~bR7kMD z6q=yaRg0S0I}Q`Z&Jo!VcIy~9?Zm8|H2J5*ZDOkny*%r2y90LUtB@>kcnfqZAiVxl z+HeLB3TPLJ!`CDgS)s^D^6x+8$DY%ooF|@$nLM>ct=fQtKfgh?C-3D#TYWMb&e>0zLJsSk&fiHi*ag5Qrwmm*dXno_GF7&N$ zxJd`PLbnb3`e8(3ymeRnD{i!Ch5NKx8MrQISWn-(&BtalW6IlMSi*FC&Y&sWFBX~~ zhtEs`12jLD8r#O0tx;Z6~ z2V|Im`b<2D;*>)eXV=uwkuPEDe3Z!V8DoI;gXtw%pKK+!} zlE)8-c*{_V?1tM9mjtg9_JVc#sxk8ywVDv*FyFA;pQ0HI3@!c^K6K5hbeFrA0aOXP zt$x8pM%&P9sEBfAY|ixN(|>?E=F9he+j1r=kk%4bBC9F(!ACp)rM{iOgEI@aAKVoqZ0k^hQ#U^Du0wcU5bc}7e}{5~tk zhkTGExI_2uanC-;wT8#*Pne}z59_^d~-$o6O3K&@=8lki{x3D|M7LR9e1%X)dJ zA1W9!u_22G68cyIcNz7@3*T{Vbcd!*=sFqz7aQ>xj0Ry1tD)jZurzD!m?dX)rK$cj zoa)h6;68`li20SyYwgc|)Mcx$TjeJL$IVaw^pXdesA_eC*SQK#*Ci3A-JgSO>vpNz zlGT&e3!%!ts?)W84@Mo8_BIX7D2n5GIL(!jn`iS}O3Pd^qCQHHpz^;Qpin?p9&Q9A ziS*k;&;~_c(^n)>m3jS_tioL_2M% z@1bI@#gqLl^RK1Evli+ReeXvLdqI=wfMbR~cU#DG2zIRLFuUaW`PYSL zFs?yu7`h1whikt+Z65a!haUbtVBqI263uN*UO`RxrQ;)ZXMP8XVO+WQ>8kAL09pdq z(pc$zTIZz$|7pp2{C#Z(wiM%1cJFa{uilp299{pn*jkgllP_z&zLRfn|z%%OOK}&27ThM~fE5pPT`t|@t^IkxGB1nxaTgLoYyDdT-=Y&` z*|)tNYcTKi>y41-6dU)9)qYt)gNL-bNEzs(ZNI-@-e+FUmF0}|wHPb@#&oNf*=~h2 z?CkPQ5NGvf<%Ujv5c8UN<(B0B>AN|58iN?DOrqAOk)Sy#9qmlp_*}uGHL^v&eZKv3 zbBD;cHd#K-5V<9C~7x{j7ygEj$WBrCT`e+RB zL4Q#4l?SG3B-T4VPey_JdY%g%C#QxgrgXz$=ylHpixKI0l3KKsThpBge98ZtcleG)~=L0?@dF;W0 zn2r;~=BEXUFgW3Hz+wRxDts4NjFvl@!EZ5ep6h{5M5qf5#e&}M4e33ipZuAIm>*6b z=kD+x#g}Hl*Zi8{EhlcTs)a2+t;ILX$B))dZL&5j7W0APuSsE8pUp4UFs?@f(>Gz+ z#Q|3wTR*u9A9GQ2@a9C!ju|37OV;D~HjW|a`&uHu-dnAkY^i(g}mi7~U^qcem1$lNajtl#^2_IR`<1JiR>4m_{BeAyDgd9qWv zn7tT2&#hY`F;_F^n3QE8Gdh%g_Lc#h_r3C7#~EXWQ;vw^$5UgPJg@h9cWm2f-P}pm zsQzQVMzUM-uy$Sc=PzDaY(afwnE}pEycy|=jlcZ~_-{A_l{(*cAK;4I60`VZl)ZD0 zID7T!&v|>f0(48$S$;9jv7dSra3WH{f8+OeL26HyB`$OzD_Ho-{k-9f)c@jdW^8Wc z{OCdU9S>qLMsAQcMX=P}GRCw!qjUJd0^`C>3A~$zXEkslzdKWfP$a)JDR>8`l}aiC z<;xE_Ye4|w?>}&Zkb%&~x%+<#oOuCUtH;uB*{Zl3#uk~t|JiMC+%U1h%&a?)+=&_3 zJGLjfuz>3Mw6&II7Mz9gHG*45{Cr)i*_xtrZOL3vMzJZ+E#6$M79g)Ah*Pt%#Z_Pw zt1V4Ki4x_>XR5urD+b4{z4|S^7LC?Due6b~^q117pE}5OBcp}d#h;wy=pG}O`JzpJ zqDLO>$MG!m(xNVg$PW|Ff2!kFQCYT997{%_&`zPuwf5@Gp+8b{87TGFTdPU`xbebV zLK39dzx~Gc+*rR!3WG4c;JnGl17>IE1*-9>!;)y9H?A>-+ip>3R=$mLUn=5zM{LaN z6`9ePP6pO>{Y|-wq2%1j{fRGL=3>~nukp(X=1+`0C*j5ojLRpCO`bND9XqPu1kFU`%{p6;HP`x;9UL~``+?LBu@5ozJh$W)SX#!HItzPgFb_Fj zl&>1&(;h6~s?L6A0qNDO+l>=>T!6!nq(tdj+__qeh9Lo8wbBa%W+MHhP@WBA$B}Sz<6RYO3Bbl zQ%-7Bi)p{W(+V9H%Ab|($x1RbURjFt&kstNbGV%>E8O^!+ULTUY1#>*JPj z>-&40Gcz8iJXpwvB(rG$fzM;f#@ltu*VAmH>p`i?5>6bU3nkH#Jx)FqBOSaIR|y4I z1c6#&jG`=0c~h2cC(V4EyCb7Qi+Pq2Y&^ zDUYLtbbib6P=7xt(SfG&+(XQWcpYx^)8?839BMsG%kXfL|F9%|Z|!i7p)?tVnBj^*K) z_I!dkr$_UC9&IUk4X$6`?Ap~Rp1$Y3mO;aJV(~;&_ii45)Ba?E6WSO8yi*@X zCmJSXl5|*m+=J@`||E>Zdk7L^M2UNoXq)H>p`cXCqhOo z^?{nvdc@Zo;hzYojLHfP-|eVvV<_)8Wwn>fh*+QkE0(s|$>LQ5cE4-0ytbAjON`BX z4Lb#oHAHhKthQds(4R5}vzuY{)|pw9y>eF$EU@SEus4Fc>FGQZr!b;hjyrdYqvy}v z;?s4j=j=}Urg6i1;1<(8{_N4|g$Arm{`Y`3(rHtVEn!)ii_etubwRR^quK1HUylo6 zQGp-yNl82M^3q(2e~8rk48LEw={U&&=@myK#2UwXPpmy(Z*U5|3t^WS!+cW)u%5_2oglc!f=X$fkL|zzg5PeSc55zcZN4e`zFBxR;~5f1IkYvA zx+B%Dy-e(^`F9qMcy*f%pO)9SQVMV5*d>lgb?9#{oEfZD9M7ZfUL!aUTb@NAFOu+G z2WC+m=>5Y!9onZ&jqlCY zHKj9&eWjmo@CS9%nkRE%PZZ`HkV^IZsB3#}K5Q_%OSI?fF0h*_?TPWY4#&6DmQ4%m z7knIv?4RtcC4%}@N9g0z3Sh_v9o3VCPibkM6|5waHI@V7Ymj>N+~Fle(ZzAhQ+w;K z`Be1K!clg)rHCvnVVewIHO8y}fh?4%TN*Rh_iEh<;&`Q&Q-1CAsbm0O&uEd&`({u{ zUTj&!btLsT)VzE5n1@zWHm1>kUwe^fePFfm+VbzATIBV)DCQfpYyuQBWs3o21FMzC;`2No}f zz;v*(=h8^9C1}V%3qRy6JG}E##ROG;+*vodL8FQgWLrZhK4uf9kuXIZSVhd>qHxkV zdskvS8~KHJlB?M`@_0|U_o5+~@MMcfD5R{qKN^3cmEn2~Dde69ZIx}(ZH%j_T;%8N zU`<)A$&{H_PCJfLN)L27D?}K^kECmQg#QL#OeK6V!ofUS_e%RQFCPEh8`7t z#2%Y%?oAug_V55G;NkB#Xvsjm7bytJP^f%zfil`n{KV?-Fr1K;t;~nz7=yB;s#^6@ z@==i+9YN~|*XIGrxdy^(Np%M7)aHP1h@I;|3WO)5WgK1`;k&G%ZW(*+L!I-Qcdwq7 zvbYP6XSdnv*@8K18*-zw0Ocjax|Yp4yTyTTmTg}A)gy_XKTfQt1LrH_Hz;KcjMx9q z-kTuEZ6jTSsVeP~CA;mfd-|VS&N=2lbEmJ{-CktblB#Oni$D+o5C8!#Sy@t1$|4Ym zc(EV=f=e<}byHKh(8rw4%@HX%_C#{E#PL!LQANViuWDt*-}>BAQFH*gJhvU7r~%n) zLp8n)?0FMWBGTYxfH@rcw%7i-@EIsF`JX{g2x*M0>a?+Ogaz!o*;YMX&p4}u=^IB? zj)jKYt~@9U;!Dl!QeB?4fLm>;hJH?7!2Y#zeJ1qQRd9;v>2ATN2&Ywaod9UK$Y>ak4kexc1mOH_qNx5s%2DaR$m_l zEa?LNnQwhKbGb;Nj(N#3N%&K z5H;!}lY13XBC|_{4*1TCWnPfE!2y9c7edE~UA7npEqJd%AwNxuofw*@g^Xg8$9P$$ ze*R6JCZ)9bMN!&OCGIwDt9%>%m{x{L5~VZ~Ra9C(RGJg=t#(F2+oR(9Z4W%bkU+$I zOK|0DRU@7!(!J@ZW@Mt(s*R&6!=*GJZW6x{doX^(5H5`?>wn4Ll{rRP!EJwxXYZ*7AvA=Kr&2c|sj>y&w_Z_2LzaIl|zv~!h7>u``$&$C@Tz{Edlk;@D|S+5T!(6U zNPopgUQ{Vf8u#SLNZdzwTkh-?HBmYl|HtvT~u!x?n`GllO?2OgYtQ zLq8kxt(xZy(uP1)aWm`ut;VcrUWGjyOq$~zM3LK^{f5R-&+e$mO>;zpr^g+`ABuYq za6eDg$9@QKcH~0A*L`#k8CqdpwqG-$k`Eqrf(pFmw?@fx3T0{*ITO~!DdQ^tz(hX% zupF?7AZ_GQZYr3ySNg~EQLeSkk8@@25zI2eGF9hbKT^vwW^>sl%JVa};dikSO_h~i zIM>(}y`pi<-~+gmc*6hjC>~(cBjjTQc8oxh;|sVp&n&Rbnn!fx!ny2P8*z#SsAKp2;TNBL;(a&%V9{8wTA^*eClBI1UN+0l0|+J5+QzsB6GIb7QS(Q=bHY zOR#CJFOPDU8v(9{^IYq-*2}f?lh^a?=yLuW(Hz?&rx@O^RxH{+wj9W&!}04Iq^TVLlFx^UKkjV5 zu^P4&>ev|G9fZFUhyZ*Z^_eVl43iRAsZ;H;?FSIjXNDfk>&uF?>x zH$h(iI~Jf;VSQVW6<$v#6kmhH58(L?2JQo>vXfs*vd^avYZF1jonx!?sUKF0W>he| zoZvC%>Or2^xd^g&O~efanrHbg>fJ$Hda1*vmU{NveBsuTxW_j7%FbBWhI|5CGEXdE zUwkuSl#k6lY?kr89Qr*}RWQD)UaI)GOkULwI&Ck57dS*;f!<5%wU?>YYvz4rLQneB z-^KY9j@q~WTE28)^>)NcKJCAjl`DY<#4Im*;@<;ncrD(hE}3H^hdU&Q<oawenR1uUEu-cCIY8ve)0#dw0+f%LB}egdKba=}Eq@VTQ6p>EpB zdvZJH%1qr<38|<~EbEr9GVTml)$<@48o?+wkyvcVydB5|>F7+nTJ6#8Oq|9~74WbI zKlYnS!uV?PghqMYOb?^+AhvPNFyapT9dWzkG&8#+7sLE9JJD%OQ|s-S8JH0E#;xI1 z58miA8*0K6hEk5BHx+m|<+LBio0V-pIalDB+VVn#%Ud?p*}o;frQdPDb0${x)G$(k zkVzP`8i%$+l4cWcinO&h^J#~STL*Pi|6ga@&ZSc;g9ss4#%2R=5aE|He`dy#EcRI{83Mqtyt6*Y%;rO++v?E zt4-EkCH7T1XzM|J=fcoGfP-UR_K94ei2db#FH=3o!zf?GSl%U6)*~DxU-w22%g2_v z-@=D+X3?2*L`o?aY&`g(TH=c+Rto*FvO9YHGi_h{UXBrKV~O7P{Wp)52VTk)&pSur zF^3$2l0YSuf?!4xkU^OY!dH8bv0i3Nna4yMJ4GD0R*fVYa?gb~5a~-<8!t0LFFH0M(Qz1tO%`ihwLmuEh5ZqIv7mCe*=+2I1EW75 zrEQgl@40HadUp2 z?U9@}=aag-OYPfz*`TWd8J1hqijKC{+ zDC0^!0LBd{Pe)NIL+yhI%$QrcfN=rHHrVsJ^l}koZIP6=E^>xIpQg z8|_-gkWF5lr1`lSF^2+iKTN2ZXig9^ROEnXFc4Vzr6*G z3j{Jx*@*T#o2(6I;f{=|U-eh=$#kzn=$6vl*S{^HP3E+KA6T12YB^kod)0r4g|XfC z9ybvj9*~LxjwQLV(m6>d)6&y@%Yx**` z)%q}})24)$DNEWi-rO?JOBhRQ4@oK zN6u^dJde?IAJM#Q%ClS}{-``P`}i+w>E}pnfuecT`mv0&_gF5u*XbDTtoMy+j(f2I zYcB6k;%j7;QjXo?%|stmHTUyD8UJYP;St!9_a^($a_o}rrfv5b&r68SIc+dqHqb_5 ze{^5vwjnWQYaFwC(>_0dLr--BZ|5Prna8npsqRavLaXwaGXmk5vo(BY8mjj@xJqMtoc1&D#$>cx@O*xt$h% zp=aG8qt(&8tS=LTY>_pVtd0A8Q!eIgQX(*zcHL$VWM((!IT^R){TA7C`qqY_}RJ%Mm8?zV`ffi>E&4P z+t#t+u|~?`)`-9+-qhy_N@6h(zY5HRw|AkWZ@}E`=FosKh4+mev z!A^#y1H?7koVxaiob~G-ScbtEl;$RDf5?0BOAZVV!;7B& zUDH>2o_jrc+eoBx*&bY(d&0?y@1Gvo5!<%#=60VKP=*HGhw4y zeaoI$XYBtirVh*A+{D*c4GUKUBq#2B?na00nFHqPMfhgMbnNZjrY%-pl;k_Ym%7Zy z(~o;%`?i*@rM4W`^CxX?Ov>1-%vp(y2OBJxa7KL|<)?|cUc<=gJ?@xn4{y#RvZYB7 zNwZ{2O}1oDiI&^4wr+OjI*dMwyH+mOPrbw{(MvX!c=?vX(#-{q>5`q)<-1cxzsv)k zTGGCa=6x*LwGI7I`df&A&!HuMA9HLeLTNike{Xyl+od%_ioU(JM}PMEx82(H2z$=L z=I>sy+XH8PWF94tj)3G#Wo%n6(S|$^uEG@*^4RY~9c}Bm@#&koIX?4Q?DDvs+lPm* zPmWT~4O!*7`nOYLPVITDs6|e4w=ySCW=&4(Sc>Dw#)r2d=kaY9RYoVw?Wb?^-oXqq zTQ{BCPqh_x#PC7{O}z{=Q#-Mpy}HZ>rk2b%*hQz;!ZkdOgYLvb<;;QU*3wY z;m{uI?6<8d6Zy5iNZ+0*_KCr+vetG7vAuXur^#Wwg1s~uY#ng$05<`rq}9hE73?_yLs$27;EL%$gPoC@=0d*j&3XJcH^bk?v&^K zCZZjANjYMd=09&X$FCl#RH@*524@S`gxyoA^U*^T%e1xFkW!^q@twz8zmb=yDMH49 zM!WgIcoE_5WItah74bJmshHrwyUI&_-vx4yOncdHIo|7vbSWE0Ao;5KB^+08K)CZj5^6Jycl(=L9~6XwYbDyinjl%#P*r9Ow} zV4TpE*4xKjzUOLOy{bn{$t;JmqY^W~Zqv*=22|y{7R}bjmRxUasGClEh1u#`5r92PXllO_4RMio9klT4!ogF@EpzKD@tIb zZWQg7iRTL(vZLRWBd;D$DCpu9KxgvubqJ4SWhZN-$9+2jA;xW{d8=Ij@Gb18!`1~} z(PQ~RML_4bX!0?6!sFTfuN!PWi{3U$ey(e88Y8g9kFss%4}njjv~IXynj zOj|XNT1;#|L+vAAz7HW0{sTgyxT}2Zb74;|`fu0y_QXxFe85AQzxmj!cPPh*@AT<- zf|q-AbP;1tJm;~AvFX{mVz>wehK0QD($iybg{S83Hofyctf+V7g3Mz4ocNTa1su;d zP(JI8gr zi_j)-r!Dk)wB+vua0x1LBV=XH_WsOl zsNswK1t%(|&u}YzHL|ynJaar6TDE8D<{sl7V87gQ}dxkr>?CSEqrSm z(P93tku{mK?91J*c|UdRSuA-}a_JE$_ulgPXK(ybJn^+M z#(Z2`e4{!qmH!+*`k3bP^QFe+D7$2Il@?MvC_G1BdNZY8F^=5w!#6*c5rK2}$=4Bd zI|gxo{dKt1>4nXYe$MO^{i@iujGHa8mmI{=oY*A}9D)lIytm8pcfUB=Trfx6#lJc1 zVHAzcLNEukpYg^54)U^Kj>c=BI~5^l7g}TYyu0`i!u+k-O?!2<0tp?cVzIcf*Y`!qm!n*eKEt z{L8-~!QU6?JwKLw9_fls4=fit*>`25J69W0M#53(aLMZhOLcxwCXNALUl2{2*UBXz zqm^mi8x@TF`M4kT!}GJ8<3&x4ImR>XRc35fjuY_#W0E&hTmGo2daU9luGXegz4A;k zLsf1|v_x0>(6U8Moh$V=mgH;kB$C{B#`s}5^0BV-pZcsi<*ydSe+tR*lLetqN8D*% zLfHabBAFayG5vx&&ENPPuqC10EH9tr0kTE`P0ogkOxMADg|g z&E?J&qF(zj(NE`7=d$fwdU&Wl;%U(C*iwHy_-=B0NvCh$qSNEh;4N4^4pUz|`pqS% z|A-~mT+{IsAKQ_*Qm)01W^5Y87T@t6K2#0NFuTUZIJPqK@_m$fFU&hj$Qm87?*SFtm>#qnJ;AmCK>)a34&&iL$Yb2EmFQ)~VV@esQt zrq+YU1gd5VeXxtq;!kt@(kBMeM{;gATXDK3ju@+QY0EJlwovD{Le#ps?JX3xnFn(5 zKVjxg1w|!!@?UuL1Ya)1fQ2=A0vQC65Hn!obfw0$Fw~%j&huq~?f4^eT{Nph;_GFR zIJ#J@ISvp*$H~_lr~C5kl4F}(q8pz!${__#9P^w#TymBrE}A;UXhnS1_WfG9vjmcH z*x02b{*CaK{HJ`cy*z$B^iBL7%+(hAXhcT)Ym|Sff3&~PH|sEVrgO=*(RP1%NaI9= zz8>k1tMz^#?x+tBjlVz67B7q6L-X`#do4ak>p2GB&Moa+Z%K>f4aeqMw~S} z9XO;dnVq$lEy$RLo~*k28s#J@K&JwatrUZgV6uKF$p}y&Tnx8~7@C zyq=PtU?mpKD%NL;+G|6EX+=!d%Mt@0h$x$u6luwV9;6iaQff8NY95ilCKG&O4lJ;k z_pN?1p1AJ>jRL!u(sA8_uf?w=({3~G`)vSTWjwYuY3q=l9>86BEE*A%l`xW~sizW=gO3Yab0M{uYr^_5 zus0QYqd4I)hC9beV(*;OZmRp{Sf$vCgYjC|^Oc|vIrq(b8)Si0#2Kz*GM}J36u@KvSRKA1%DU*pVULCSXiWeKq7AAO1a#x0vMh6w_ z>h%@*DJ%zKrI8Y-)86ps`-nAfD^OHLp&=#dAWUg%YBEVf=j2bvg2PM{*)>WVGJ+ld zG&l#F%;-U}dt;ZYT@|BcVPzA8w{ZELixoriEeabE)^>E|Ep-Kvf|&*;`(^(l2eEjmKcW*ND$9R&C->LaZ-GNR zy|I3yC;Fh+TtZ%IV*NIG)lxlC?o zxoN{=JNfF5BjOxrFhcG>U7*8&{r(Y2p+AIYN3L;;j zv6GZz>@gpgRC_&Jba))<$+3KU-d3{fk#;(rFLm>BZ4i~ zDik+TY|O+ZA1Ow?pf0xyCeHRz>nvWQa`(c2fnCs!Lo08PT^stXt#@r}%igt^{c_U1 zy)hZd1+Lm0?vS3${Bw<$bLq+4bJck+WMOo?Jw@O>!6o3z3VO4@u77*1>Eao2xRY1> z^tl@kGKooaXKI=Xkww}w`bZ2r&6UGmF|?U(T2`_tT1*D~_i_->eBiBtbmPMSK3BxI zkUKfA52q>tmSbX(JUoHpF+a!k_0)>XuZ`P2dJ@|ogWQ(he9YzgH9m5FEfF{(2XaiE zC(_yC-0|Za+~z1hkX31?JHuD>v^a#JKAm^f9h(* zE#bwQpfq|*eYn*J)7!Gm>)?YB7^r3>;))R}~zR;zdF4B5% zhYWsXzSsy@E?umFYYmgby5@5a#NIi7-ni;H#NIwIZZ$tV{Rq81eYO{m_l1~S{qcBP zv3h;19Al>~1`Y>t9(>st+NB;Uc8Hg~@fjmA<)6ZOOj4%09{q${ zIrsfbJxK=*z{?ay-zOUfgwMk2I&BAibd_8G)UEtIe@89d$A(mET5DT9qTj}WBFbrP z9bEFM-fS&m>p?%*Vmg|8HK56oG=kq=nW9sGMqB?TobAe}UWYcMYl9xAa$B*G3p!t| zgcYCgoi<)s*8eMk%3{RrnF@7ox89&kV>BTK#yM#Pe^G+GGv-Mu6x7D)_(CdV$~F{q zJ8N=P@@1o3U8_!jmhVCKntTPvBnqWKEcL@=c!<5!uOy~~i;#K~AeaFQ|Phrn~T7jJbti2 ziWA)BxsXn=_iCRk6VmDWy!IRz3vBJ_y3$+~JMH-~Ux!=1Np z@L-!v4Z)x8G^8%Cdnf-rOn)r8trqxxTyM=|`5{NZa^3b1*_X-oEzW$42ZJeaTHrj; zvn@?^z4M7z*K_O;z23kJ?6alVc=Put>p8Ub+r9xg^u~f*AqW=~V(0TB4lh#Sbsgdh z>n!qROzK?ZdX zN!5pvJu|;bOk*^OOqL%_gk5ZaR^LU?U+SUU4vO^fnViN}C1k=)lp2@YcY+V~nSknb$%LZ-q(^c;VzZ5-kb3q_G`e0I>JBS8ZRFpzq6;cpgjOf?6)dUrw{r2T<$-4wc zrHCLm34NP@D8atzAfdJZv7KedCzc3F9blj2xvti(`#XX)nhzt`>#X-uhN_Q|D;?|Z+wuzPg%=-stUIY9Rzkl~n`k7Yabnod22 zl(Lt{?GoK2-CjWJyon9q)fEBdW67a)7Y%o5T!>})!LnP+oRQyxkRMbIzSKRR%dVy5 zI`YW%#DXCh{SAd4+_gS^dvff>yeIQ0mKzI%=KGkraktOnNsttCGME>Ju66ONoRdg3*TIm-WNLp%VC z#BT4n**v&6uKJYb_OhSz6;65j$^zm<5Vm zn6FrnlCxt3F|Isl$~`S0j4XBsbBxxrR}uOIZ}7208xP4k!&9i*ry0@!1yRJZribg7;_Cs4cQym&DpS!#6yZA0x0a0vwm6u}N6Jdi830^HEN4 zZL;wI^;sw3I=bThZu#W-yiGbJ=O@~phvn)V8yIDt0F$0!i4#Goe+K<8q5($2Q5uWK4mAS6)sB>q%EFYjwQ$*^Fa* zZ4#LDSb^&%EkD8Ug^m0sjv?fG9T&%p3Li%w3$AVGay@#^O7?>}?Z~}FSyyGu3}JW3 zovfzB<=munrX60K^;O8jlHz@?la-K5z3KVbs|~|E>yBlPp;>%(#DAzzcei1zoWDy# z$kLH6wS|td<6|r4{N1oy9uEo|bAR@b7;{p7-?1Q2AW1%5Va?;>b_I2=T9Aq)Ki5WT zUrGF@rh>Ab*wnOak*#6wLBHdxtQ!-SUth~Z{}rYF{M=Y17pxNYxUs;4xPBNE&Ky2C z)_KyF3A0b4t?Z<5;Fiu}nT0qBnjYm7>C=^bb3r*x7uCr37vw;THp2`16Tiif9WuQv z<=1Q_ABm&-#}FgdE#$@U8Guo_aMB3OSR5fP5ts!0+Gf<@*eXSfp*2&sWR}>(Ny8X- z*aI$^Ja&M*SO{GN*Q3Xlv{j-b{>T)bG$xgq&pxjte@Y!XlhW~RWRGDq?oBhT8{I^< z)0P&t*-%W>mf*^^lCQP+tWsqyCYTy&{D=Qmu^M`cI(Gu;p`ErUn< zGo52;lRx2V#o6XpJATo=7!Qvto?^Vj*36flDnS5{W0;;%zuPMLBlQ{!Q)m>GbCCy| zENH|@9M@&Rpug8aE?!F0g(YvS;Hd6@%LC2`hn(qhksG~ZDL_hzEE+-89oy6*&dbg! zC5FE>dcxFEu~^qwm)cEE{Ky<=W=FVh&+mq@D5& z@CI%wwMI*du(dY5dNcqn&p)Yi`55K^Ye1C0e8YUy95>LM%UkVuDU`x{Mme{RXU0jB zhrx@oXIxC1oFs{_UVTPT{so;!0 zLRg-o6iq|8?8}I5@|N_|t#TYv^k)7|tVEMQO%xV{I=Q3mkv26%88jMo$u^m@C+-L# zdJP1X1gA0PQv}qIEBO?|Sg02>?inG`J8Mk|r5LL`^c585*d_T?!+tgGKNsK$x?t$k zRxX&`@il+-IoS~CyT)W(pJ64z-Z9%<@)M6#O- zq*!2VubANA4T+sM>c|sntqQ@ypcG%DYZJb7T*w{r2w#^n1*0fSrSvPqI3t{FN>xeP zR-%`2zfNj@TvPugFKfafuC>@Qqidq67GG1eI6iz^xomk}vm?RMBXJq1LQUi=JWJ#~ zFqU*A?J^(}id4*wz2=3vhCYhf(a8->th z@R#n_U0D#0&268lq#WnD{}GcVkEOl4EKa16wIqht+0C5uK9f4WTJMV^WEcSWtQ?Qc9KyLQOs4eI0 z+D24H%lwPUWkvDnG{~q2&gANPgS6;0fgLyplB-UpD_z@cu-RUj&{h(h z5YmE|?HMSZKSfGwU04t)-H{`bPKF_S=uaV$$QQQ)LnVe$rcv^;?w}3dQ6QF98qg_L%k!L`*E``?vF)L21jEm5^FB#k z7{N^?&tL2Ljg*ok`*E&2Sm< zc-k0|4g5QAzG_MRIaiNJ=6_65CM5u6Qr z?^ColzLw9s+d2^Hd=0sWEiz69fZ`bF5enQ)EVoU5Ecw3F?l5KINa}fI2rL(#>N?l- zt&5pkN99fL4YA4adoD;E4zR)c;{fPgA$d$1x&tsB|2oXb##c)sKUPouQWzek*B!L5 z^ENrY=fpx?`jE$*f(-@sB8f9h`DGn*cJn!p#hmloqtv=!``Um#R73yv;#~7sj+6*? zJvSLc;i^Hbq7!wgcc|6IPD_dT(Bk=8+jJ^!_1}W$I?xuF=FQSKp#!g&KY?XhYt2Qv zDLlW?GpdRRlsDTeVxG+Fpx)M=JoECGT9#wm$g%t&M-n?^fa+MD&d*DFcd%Q2V?o}{ zd=ryW&itLikQEk&{i!@yj^P2`JgRBF9Wx5#npwnumKH{l_(pAoQdiREv^N;vv zvTLz8kj!_!(n&UqEXIUek1b@qW|Ei?1(!BIdL3`bHKDL9bx(A>TZ_I2w_+voJKIY; zhinD2=J0|b`m3jZ6WV1pqE! z7KApD`cj$;YaYYgsXm=^RT^=}_a=Kl^2>pd@ns|w#{$QTGq%Llg)E$(UYP_5(J5jg zZN0?)?7e_YyN=uzbL&Xu9oU+qF{zUN>5`AK9qe5!xee0y6*VLA&2blvflkdk&aGo3 zwT6ADy)~tQ&Jb3@xFFfr>|dplL<I~_}AFV-zmJC=i?;~*WUUdrdHr$3hK5%6=% z$Z<*LN5E#MN)<(zLv&zG?b3_5#%FVjHR!nWeVlU`=X}p&>h&F8ay#ERi)AKEOtfuz zysO7@?Rhe3Doj{=75Z%2OPk{4ePrm%u`$kl_ToE}kv}ltLk!Yg>xUUPsK<>3YVwJ< z0WFB?@lr7*h*CtFqCq~?C`@d?hjb~0y#fRtY+ah*9WKYsCvgk@3>@*t$ zx+X>GVD&}+s_xvD`b49@GLiF_Jdg=Ml*}(|d)<^3vq~V3v}y!2Ypczh8hZM7lY$Q6 zbK0;~*H-yjIJ$B30aXJ8y&JQEQEI9k@O$<3%xxvTC0Y%u#7mmDaa1&kVr zUX~r`U@-_bWFjS8vSe%`UH${kPG-*M-E%q;r<=EESTIVC-(lg&KA6sjNK+nt?=dFbHsN;3Z~ZGb7nD`_^$!Wb#z0N+9V>um z;?hiN`3UXI08d%kX*g4n5#pz3qs?tz=JC=P>(Nx>DUFjF?+AShuE|<%jp!ZawX89n zqw-DuYO*(_HZ}IHlj!e$@wF&=)0gwwbF*AR$$9enntn5xHU66{?N}q9+rLNM+tk8q z*_&g`c+oj>92xXG$EAI1`k8W)+wHU>+pb%_uGj^93cyro!xnue5%OVR``D04c>P%wWY6QXI>HSx%*Fhty0r<5ah>pqh(|L+0=0&M=#o;zhnCp4>C( zN%q%JFmjZyweExB%j^8Ler!oI8+3~$qwKjCTSmvg3IjyXv96?6L6;qu*w7jeG|L{y zsGDf#8lFRQft1@Y(eg$usiX;Kk}hcD*hJf=&6`jFV)K%Y!=|x0OmCc5oyfQ7moAlWJs$}9G$ zQ6lT=fos4L#qk5YTe!(c-1g|ASIH2Dh%K_j?Y6jThfJMKeQTPDM(VN)I*ey2$(As_ z#rD4FA~GF|sQ&zmTD27G_t6LEv|*dB`Y72PHx3{fu$z(Q00(q0A*X0eg}Le)WzRWc z3)U7{^KA>p7FpA8`?MQ)Tu3S8E2Tn-W|&KRYOv1|ue9%WY*4HHg%9=C{0{BGI*r8@ zZYiLIxs2iVDTb3zUj(>+-iYx@NbZHbX{YKvd9)# zRU;~{SrKzwnlh)QfcIGQo#K4SEz(%z)W^C-Ky$GXKx5pvKP;1Ey!6?>S3dW~OstYI zr1tH9=w(#PzVGSlmagY~WpmdWoZeWk!9GG}#NJkHMsRXJwv4rwO}U=E>Rars+Q4Bk z09+bHdaRviZ;4mz7?_K^-TG}r8XFB}DbXzcR5edWC87!F9({D!&%J0J-Vgq9& zcFUm>wTHw@Y*?80I_=vhHkX7Vy|ar!?7P-xckANZ&Ah&+6CGGf zV}|C{O>DJl#3ossJn`m&ypj=^$$SV6S$$nE_p`ga}=97ooqs9lK zY$B5-f#F;YMm!t+;zuOIb=aO_7yoy4p^a0zq6;geur-kJmI$o6wJvI zyMK#1qfCGlf;}_Didbc4MfS@r!PeZrq`x!E$=hltdybbueAU(5_oY}}S}=0^_pr~8 zl@eZ0PRTSN!aBJ8>`}tpLOUV`TkLt1-5!CYPmz#E=8w15^ggtC!22*-<{D<{l!ogN zzH!0McZ)h~F$A!sImG07EY=yqD7v?PpkcjOl$_*JxaHcQo*a-ql-d5jSW7BLE4OKh!}ZvMP3?N$Awk2t(XH^7WMes zlDD=coYojI4BJmw=+3d($q>f(lg=P2nS_9+J)G7?wU`bc3({DR-MS%%M-4iN-~2Rq z@2}C0-tT7vG7tx6%|nd)xbWWWg4|djiY0+ZJ|Jq@mKUTe zjr_{vWpk|9X87Ow{+3U64hI1eNT$*>QlbsReMRN_=EAMz?ObW33>-6Md;pi&rA^iYXs}m|9M<=+xyLoyShGPd9&5XI zM{t2Zlhfdq+`dQ}^KRzDN=xsMNP^`9wG!)6#Tc*NoLch1awz8$9f;xhh|iJ2G0zMp z=6ph!3sopGN4ay43ftji=>hYpp-pmI+6NL6xW0#5@#6eeo#QwbvP>a`uSMdXTJ#eg zAjQrr^;|Nxjg_E-^N`zgT8eS-?b_y;QIy#ET){QX<|cuN_)XqQ<4h;`gvNMngRW?- zlYM+zIZ#I^#IVI&XcHTD^4t8GZY&h^<@q?Vm<@VifHk8Si@$lG(-yzMz`%tXXP!{k zNl57`Cwu~_xSDt12sxZ*CQo7G%6#_)AP2M@u|sY43}>#CDNY8FZ{? z#xKfd{6|({i_I%k!+uyaj|_Wvm{*#W&AwtRY4qM3^mjkm=4~Cd!|9og0M|y?S$jj5 ztcgPZ;4I~s5K$RHFN~Xboohw6bn2+)a(LS}778*{8-GZeHUFUiQdxdmf#uN3lOu^b zc*C9v&wqT*kpB#ad_F0yOpfHFp5XVq`J}3uVxFmgC9Y|Rml_+injAHjx0tv!*|OXt zwKoFwxUz+xdhgU*dbK|UzBLn|Oc_hg2~$1(OX5j8x}r;GB6L|5nVWVA$9fQ*Z!Rk? zz1GxiZ;#?Rei}bp5YG8(l-j&txaIbCYfV?3E?UB%SQ^99PAKqC<)p4Q+wzyQqDB>e z*~BaTLDp_6@HKM${(?2@BRr8mVo_0HuBA9P?&YI42+b9x9$2jG_i;gORJSUD-SxaN z{`=e)95r(g6U$9mk2P3Ll+clzvDp#F7{L^*rhjEh(t=UrOD- zW_oelXSIkFaSk_$fc+pC=XTS#jLG2AffN1SYF7HH^N zO!RC+)Y=*>bIY9bV%bRE%+U^^l;)kHBKoqz{?Yl#g=~$wqUn#so(M3rhqAi|RK~xp z^>o~;Sa;qzEh58#bf&FnBZt8mZD_h0>=->BQ>?eArSD7+_GP)iAdO{?+z%b+K`w91 z>tXUOA=7uF%-17jY~{t@3+P1VmtAwtT`t$7PdXi!B2tztQw?t%XWVvg0s4$xFs{~v zOy%Jl3zH-YYQ#>KFb((e${SfI7bm9zUyHN{2Jv+;w=|X7n0&Rb46#4l#f~7pm%Sxx z9%+mKUhr`N6XQeZi#!)uKiAmz@i9B@L$|kM@3Q!1$EOY87N0c`TV&Vh>yhcTX_>D! z_vTix6ZE6={F+YTT%I0dxooqOYSoyw=T_`p&&LbvK4#Jmhv{vL*UQm^YxpVALPfr+ zi!d{8Toy128g*T+@-OSE#q^|hpcxz26~w8>K7RX_R`8>{G{#D<{snwXM^$dtpf-$P zNE2;-?ur`{MCQ{D-)w&AVCBJ2 zj+Z^W-cF7jJyO06wq zj^lHTWq8OFtxA8=e;y+xr%Emu%^e{9G0mFqdw<`@lO_F(Sv|(cSj5+VF<@;xryC1x zg$f@HLJCNNXhO!aUIfwH;j53*iz4>z1o_d$kYG=qTno6pDv1^4Q=Q!_r@&s?MY~I_ z$I&z{oFH2RH*aH|!z^^Nj<{$f>YT9Tj%bJ>8*^Q`&usTJUp8>VuoGHQ#%F%L)5bgpA4Kd=CjxUjJ#%(>9Rd_SsTmTTKvY#JtNA0EK7YF2Z|$DXe(`A zf2sYFXsV9%2~yaowzBcA?^P>ds~oE~{h)f*A0gP&2oyW?7dxTN7b z4l<7_|CNnt5uQfAV5w@eAkXS zMJa158J4l^Sxx+?=+^v58})do^MA>nlp7o__9cE#Q`W>X-#nG|+x_I5X`6V~7R+2t zA(ul1!1*~S?NJ#W16r3EHp}yAT~WZTkrIE^z|$??2aYS09xu0@ZKTOMiFVK|I*Ky? z1(QWBdG+agwA-~a6on2!jUS`*__#f?;7+6p{6q)_r?kSGeNf{2$yDhLhrfN@t!gF!PJZy$-b>C(*?x!0I zp4cu&B`KeyOOnzRqiLBn`2?~Wg*Nhs4>9$`FGXKltHg0F+^%xbHj7gVZ=J^0v_nCW z$wUXMMpb1~zKEx~AQ$r0up@`|mi{FZ5{d^MD$h5;;GfV~;;G8y4X6uV3L+<+#+o*P z3WCUxfw^Oh3ws7397x4alLJ}(j`Fc@NHfORo|hzl<+<9)u&mE`!%QzRnl8$)zf?9N zMubRvEX4@0Z_0hX+)oi@q2xNDHVkG6CbOd%GacUup*D~8y1!LE*TXKA?v=i^j`m!f zi}O0iHU&B2(TM*x_JuR>$8?#rNVDF6_n5&3QAA$*WVj&P{7H_*4R~hy{J5af?Rc7W zs;RC!w$Mk>J_U~v(s_||L=d1_lE1-LE_l=?>b}+AGD1;qOemttkzYRgG9~r}aU^zz zMb6BHX_=ML!FrZ0MyNyG4mKuv8B{L5G9egQlI^g1ViEH*AJ|8RtNo?yNn8>c>(>dk zUXV3Ff>h;|@sQ}LvSO_3Nl~3+w1tW_xBDa!u|Bu@-~v~t`!cHVj3EY7zY!BBluYH! zI?<0t%ZIFaTUDOwkDATbNQv-qzKOIFpSJc{o6kE-QcLa8vS3Bsz>Kw5Kgy)&CTb6ny0w-30FORceEwak2pJ(k5Yt1|+$<*ZV7 zSLwxa$JCl&0^8)nR8TeY@Ss=_8%ndbwz37AQ!S$IPk2T{&Z^@2g#B#@zz74(Vx?;V z1)XT3Cmdz+0qOMZU8F5$Ll-vq<+rQ4m@k8=0z+M(6P zNFS7r4f)#`j}a?Gvx^r}jlfS3XB?N&kDW^)EHzZl1^h71%u&vL)3*-Ae_ay`4`UXB z!zP`UwO83>%~NPY{6vQt5RMtbBUseWM(hKB(m8>L94wMoNF-2ZI@r^WNpoOAJzTRS zx7jRyk|6x49N}oMY$(%Q!NoqxX|u{J-DGGRzwsjRn*1^Tls8gQicSfcRWl>ut5iz! zONF&gb~Z2{%=$vEX+G9Wwg$cZgx(4 zF5$6k6Glx02}(h^w2t(cS9Xu(rC6D1DPIVPHOhV-NRwe$^iCwY5o{9HuvZ8!nr^}% zAdlhOK2a3-2x`(fA9@KGq-slOPfRo#h^CLEgeTMMs`IfO@FtzctUj|Q9Fff!ro9Zg zM*GAlSd1A8{M2O7##r;hlD^S9@XK(jIjUfi|H8tdXHU#h75)MA%3XMnvOv>U{m>@z z@pF3%?KH$~mIscsYms)$b*j%feN=G?dlVz`)21{wvF-j!GIrUA7iinosEBV{K|1BP z>e?z_G?U{}CXVtPpgId}AaORZNyly$2)|55XtJDv4k5`O=C^&s zD!06{AXgWl0R;LZ|ALcJCrv&<1F*>>oA7g64;h zo31Cd7xLzbYFLts6E$aV*AaJ)s|hM_#}gaoLW(`{W__0P)fMyT-m_6JKB@YKU>{>2 z7uA3hd9CwKLv3hM+S+2vsHwqJaSApMT z;*4M$OY#~vBe=bCMk}QhMIEOW8%-(iWJB9W(bpT>pn*dqT1+BpDFIayGGdUQ@8)B5 z&Z89OjUUG~zMS4aj)NZA73??PM`G69X}Q&{`bd0RII&F-gI{tEBuy}0{P#n=)P%cxJ@sgNvA53IE+I&TlwJ0wh$ig)uy}8P3O`NlN zcFAurOaLgLAsK(xXn3J6lM87kAEQk>bmJTvN)Xes62$h&^1zOrm!c6%#0()UcUbVf@Xe7}O3qz=k+}d~Hwg9p= z!DCbgY#_uIJG8f;kij>-2m;r2u}7?Dj6n_W{>DOU;jd8oSKxUv1Ow!55iH_Ac0XyQ zP82%Yugyjecp+tW&>q_oj>d&tq2k(U9P=Ua}K(+LLTq)!HU#_F2@t9l69P?c0abA@f!Hr$EO`Fj{qYr#aMSPKWQgSW^id?W$37aqEYoDBNg1pFb z=CL~1sUHZFDk)9_ka|KBScE*aflgaoyN3}Q?ZiH!hY}l1FyPL32(LV}PL`6BGBO97 z!2&DrhjsmS1Eu7m1KF@m{*#j!{Lfb^X@P%FH@FFJ3nJqW$l^D7tgcT9k)qV3uY~Vm z{t<8N;Ku&87#G}O8dTfS7c zbtBJWSVANXBcnRb<}zE3&vP`T$h^`Mb2#73o1y}Z7{_EYcEq;aE#j;tBEAln#W+8t zn7ol$YfCv%>+au7a%mhbwL3VU3%;C>Re_YnrxUIf5ug4nm+!CCklJ+DCvC>iT8ImC zQTm}5i+$gvY?SF3RO?4WPmZVAcuo30GvDNNcIAnT_HaJSi&!DfLYLSDOIT*i!Ard? z$`pp@;O1MZg}+nBr}CvIs0$xC9+M^?wx|q`8?^M=a+hME2YJzrYXs`0*cC2d(3Yo9 zp9+USBSwC1W4nD~Oo(~>R?tSq`v*)zdbmrrP*U{K!?X=0}qG;dM>E?zr8s{?nMW}fNiDjJ;IU8zD$vQ>wRQd{> z<&8Rt2u%Fro}1ULWe_oc=-kX}R%!=%c^(>VKwI!5^@Z~I$h;J6q;S~Sba&zbBD4jc z#5UR|48d=-X{`A0EorkWb)f0FG!Fnbw|d|qZS^KTsWh+S^=#gM&%enI57b$v9j4Ql zYvRVs_N!aFAc>gkdmW_@8G)LAlU{m^K$B z8N)zX0wb^4;(1%a^L)NsmazbG@S;<^fGdZnCbrPF9FyZ(x*SGJ>@c{5M}QSw`X)Ff zHzA0_f7r9_DJF#L>+93gXU|U0@VUNzDmN9L;)1tM7TQlN>xXqtBJ4O~kWP!GN`mu* zw558&?kcHVY_Mt1Q-V+8S)`Xr8Xx+v@!@;fWD*P)@;F&aomimV+}z0L^Ow)3FQ31h zxWD=G1#RIV06tEyoN%RAWpA3{gQ}6RNZio=6fxG0Gd_kb@CLV~PS{{)A7oFesnr6~@fkI$$1cukXUovKFpJ7N3S5dZnC&?mYV zm-_D$fip92x^fKTRVJK2IY09s@|HItW&YGZ)A)aaIY-7Z=UAQG!7osR6Il}E#_ttQ zkTC!Sk4!;5<)`@?bNe&Q<($vSdv(pWYnk(}aAWhjFwe_7kZA=6H~&)(AFUHzi0S~# z#f=UIgHWRHCC+l*h=w2?dRBs|TcSM_dsIth-X%4@T@m8nTuk;TZo(S! ze_3V#co}TsP}Dx`v7X`RRf!h5aEt_PvV6H9C*Z*ba<}ZS=i^*YzCN3CM2h0Zy2T_? zVkzg1E%TE4g1;_ylrP0Pe&czLlTdgpNC@qUor|`)EbbQlx(;#}R~a)zFBqx*_F4?t zUe>bmvr_e_>I}v_p|MFL#F}1aPw8`G+0HeH`zAcrB>tQzt~XSd)p@?e8*g$B#q%g! z0J{n2NtC}q`xiV^CFfMwf5Cawmzyu(!-H0`AW~~&JTk}NmSzD8ILDOZI_E)gLEGg9 z2uZ+}jWQt|T(9t7yaoh&86SMe5+t;4LZlmyqbc})f5jIECu=KLx1!jZ*&%k*hm3AA z)x9}fWf*TL+6UWg^ER5#o@RU{9Bb2_3Y{8>=s(1OgLU5IxU@e@Iuj;WY1`r$AB#x@ zw0~pYCyfgg%MD{2=`WKR+2{I3WcT7tY~aOlOjPvy77v{=sd>)C^EhtgwP9tk;a(q0 z)%i?j%MgB<-zFKwCBMgb`t8PmUY=gOd@0xep35~rJy#^Zd|-?H)4?WJy21k% z)D5LjNYxyZax<6ne>fJ<{?DI2pFV&7>-6chgZKb1R}5^Iqb?tsd~88EnT<^q{?SulhDhBqT1D|qY|U{Wt* z&cMi|$^%M>@$D7$s-3izU20pT8io6m%8YO#p7EeE^{>^qMK-$Anjjld<}Q1!xqJ zT;g1gGEb06=HQoO5gBw*#2di;T;XGLHW#CqnUx=c#E|&}izxd>6zBfw#sY6}SR1iI z-4?aFn59me_pz7b0Ig6OCnovFn+1N7BFnUe1JP8s0-bDc^vJffZ^L{&#gv!GH9nqS zHBeF~UDjDrtBsTUXiKWcIN2U~yyTH(+H#y$#w*6R<%im#mS~h?tQjG{aYd6-d~1JM zwdr4eA2Zj}kM#9$?iJf@5$Wp-%Ujzo>tx>ab1&m9apYM#rdy*BF>14%TG5Z4=Xp8< zq&PWm&102HWlOVQl#hNz+O`rAM^Me$mJE4}uVkIJd5+$QDVmoo;a>_(V^sDn%^$TG zXCfV@(|e3bxzG<3tWER-s$8H!yPPkP%i6*obU7yqHx%p!gM4!UVqehCb0a=+%kwEM z!>U_cVB!r3{1X}Mc|+oh+;C9Z4Q@bu!Nn+ju!}PAg{DdnP+*xGi=fG}1b?t}k-E^i zwqgOarAXKsxW2v-ZL9tmwihO?rmNIu?iz-K!78@rnwEheb9J3&%t!4-c>-SYCt+|E zi&fElK+6w{4$UwQyipvnu>o^fQ_DV%qw=hCYrhY=x*z%t{<8mMKH%^TbFkVK=%~oKp5?y94-*^5ihS6H0Ri1yw17(Gmi)I;4ZJTeU=Az1(2AW$0P<~R-Dh#=5M?h zKlkbQV9YE)k&CeTypqaA-dSmaV?kpx@nqGBzDEJ-WX`jTM@Q-^*~^&WA8pW$2GFQE zbN7vfC`vH@u@hC$jBFgzb0o&_>j6}0(y;R%NyJXf5^wjDJDCo}D*082dM4|ue9Z+> zuJLp!W5tWsTnTZl$GT29Tr~0K04@&k0uL|#Pzu+qd7+;Z#Ov3uPjBCTcY5>s&FSSU zOdPm*z_u&7X@Cg^{BluBwE4vro~l zdaH7>-6lS9wepQHYLghMm`G{S;>30$@e5m69P=MKg&!`0x$yn;>Ce+2fBYezj~_qk zBpMdcvUmnB$D};ng9Wzq=Tm59|DoUciX?96QvIN;_6xcFhm&ucquMGtRc);|W7mNK4Ur!xDed*hgOTPZGfY;bI=i_-I{%5JO}ziyGuj9YY$$d2&#IaNw9 z($e7b2ZXs8l5Z*T?3NqHJJ{snJr{4-Xdf40G8f~9;}d?^5JZ?a{C2|g7tb+=zm~b2 zi^ONoaKji2!)MR%dCEoOGkg;YHy#QXjXB5X^Q_Gj-0MForCR>Wy&J<9KOr z{KEMNHkV>Ov2APZZDn@Zxe8@UV|fWH9mAY$YW{L2uaB6v9+On;R6|ccD7v9UU2{ro zE6;bn{rGZkyxM$>SDT~e3FFg_QBCqb=$eaZcIbF2`G6Hdhml0X$1WU%FwUdm$2A}Ynf8gLo8PcsRg+RCR(R;sEHCs%l zwRyVH$m8iIBYT9G*E901!0A4?Bm$yRA;t(yGgUUwVP?a&1gsO%?7jSXniC2 z9Qph|w@ve(;V#VqBRa@yF%V@`m~HG|ZOa;@%Eq+cOI$@7M8!uUgmGEqyC-6di!)#z z9%<~g-yT3Ue(K=$Ae-0ZToPkR+A+b&bwa-0jasN1AO2pHJ%GmZL0))&_wL>4{rB%r zuW|nP3fBUkKgao^oGXTN&8d$Vam=HD;z#FvqVp4-vwq>;_#!v-lb0glIvcN+y?FW( z1l=qz^;>LTP9OjJpx4Yke3Uu=m;e4E^S{KHF~IrpEnj8CG9mCsbfbQaZ(WH00b61Q zx@}AyeTa|Zk*@8;=JCL|0~LJM2Wd;WmgA1dmJt`I!GCf|EHnETq*piVLy?*l1c^5n z%8)&)4fYB;(^6s%WWf}*(wXegy2sj7Z=Y-EqvW03k{aPH;b@-b$y(yfX&jE&7zB59 z(d^+@lz7vCb^CDi@TPzc63HwX{qMeecY6Eg4JM3Na?y!*C!g_kMsl+O7nJxd1zxnq zhc_Xl2PODn(M4eS@RTd|2d50RGWsX}#;Ujg2RP1Ulm4bl6D2fYXUN8DExg=R-KGn< zbslMfKeZp*D`DqZZ1$=a^_pMbW;}Fpsy^vW5#=HVzUP5&cAy*RGba4IYjC`HbNb=U z4{|V&r*7_e2Nqxd3SR;Ha`XB0`-k67AAbM+^bw0?el3{abl{+6Ut0`mHxDQlIMu2t zV~c*$j81-k%FO!6X1Pb1*0^25oP1bfEVOTtRXFqk02l-X`Af{>Z%^O7`A(lOdHF*2 z@_IVXM403F{&vpioRc`0%N$R-+=Wz~oV7Whv8MAm7m9i#)W=1+ZT_$s5F8vp!N!SS zU6`uAkh9K(m3yC4Tl(c}Wf}JBj7`&1Yvf&^piR`-J2}iT;zX~c$o^W~$ms+Z`R6v) zL}>NMF|CF(r^9Ewus=Tr&iO#GBten(xN2Zm6kSR5`5gM(*fS%kD{eFUPz$$Np;nJEAMet&^^qf89fuu^<>S>s>qU~V1HBa%#AXlI_J4X(}1%TO8YV&Kjq_dtN)Vo z+c0Jq?JqF2a;w0;8!6XY^;JTJ-;Ck4JN#q(c@2pCyg|S{FNBlE^FLlEWLaM)gl{I` zVY7F*7Wn@C`_p^eOn8Ixv#S@l_>S{N+(>we`St15Gs$D-3U6d+?{E&rCizV|^fJ!U zs(+&onirI}*WV;$41+yr_0^7{1V`PBs0bSf! z{teg3KK%B(%%BJ!o&(WUt9t zWAvApM(x{A`6~7-%S2u3!?Hx=5Lt$u{WV}{i`QaoTFC2dWO?bP0%f~yENB9ns+tq* z0dxeO)@&IbQ6wI^kP%7Wa+zfx;7t}XRP3NUutLUy3!_^)kfR(OYhPP>&6@~(FB&G1 zXP78n<3je!XQ<;YBqx?DOd^cE-Cd$AH=xO2lp7gAXP8b@4-LV8x!6SkUMLsAuqOfu z!C+EZBs{c{Q@5bcs`=h=PEDd`s<}eY~fXjauIaFrc4-UE^- z25}S7DkmTuG%;K9Hxw);j1ymv#2X0mb+5Zmr|;jt!+YlNZ3f(Ikay1Ui5x71`Ar9Y z)t53he#AdB0DHDgeP9Ndeb@)noWyw1a@=eng3(y^bk_5Yyh}W}%tNpH_~gF$SR{N* zs69F?SGaRMv3Y0g9=H+xBfN{ck;ifL*SKpPsC3Q)Pvk0|E~uYke&+iuzkB;mzJ|)* zw&81vUOdG&5-^AJ!{51)Zy@jnGZ&Ol9qnKp6qyDBl{;rE^Nlh*4l~7MivWVfIN68o z>*Hn$1VvOW{ZU=cAV4H2t|`UB!V`>Xv#X(~J*KqSdo1A}E~pC)zUSMFG9%;?)Wu6J z9gLGGcstn1qQ{y^aQA|rliJ|9!M>-o-;Cl8m2F59Fs@t$=xGc*LoRF}w)eb7+qz%U znd0|wM=N6~Me#LWlI!1k5XqBVC|rP7pL#rFPV`uy3W-=kEzy=?YiLW(6}6Rx+&oJL zdYXH?#)@P-hWRPSHG_0^x7yMkDA_u(CNYd#19J54Il;*+77<`OcbP{&N>A~V!c>b4 z#nM176y?uN%=`*{-R*^}6*6u>K9Q?1&X;Nq>4hjF2#V<^F3a4rO&W=(_G%R~1FTp@w=#ul_F#aZ!Y4(HLQtP?qO6Hh@{8^)i;g0#yHmr!x z+(_Lmf{f_N0XkLWVu#jcEej1}^Y)%5F)I9wY#SpvD6Z!kBlaE7Yc`d~BYKnb6DA^l zBf;mpuS?%!(7b*7Rvs*V{fgHGU!Gp!0kv1*)kZJ)8uTZ)k$?=*Tw$hYS$hsQDR^^% z10xI$ec;V9OMBONr^t{1L^(5s4ce}MMMB!CRx^|5UGX6}l5I7s?UKPb=jHGJ@j=~Z zcYHWUrSbO{OautvIF;rdcVauH!r{d63jP1~zX|d&*R^wd5NY++i6t zgW#khzkMLDxxftsTzqbYDzlHW>@Q&0Zn7QY?}Y??&H zVj2er7l5MF%t90C$}7G}SudAJB2hTdDC84MB7rs?FC`BrHA$hA)E+ls?x<+G(U7eh zC#B#A_v(5)p(l-G>?P0Hfq|Vt6bs10g)08Z-CQYgvediFyzz5$!i0%_3l zD<{bhA3mIZ{q@(=pSV*iKNO88ark35{A#d1Nu&OWreLS3H_x@6#v-ld5+*T;t`tG z^u{jk+T#hlmssraHFW&?=`$?$UcGvSn~UF{-iEKG^5y|wVWhVV49!NPS&2RHWx0gC z%pZ30FO#4yq@pwzjhM>;qo(4o7G$UWN=&6wi-s@qK^ZpwaWo%;4P{*jszKAdD|kxN zzjFn%i=h^#z-oSZN%4(06}X5NU)nmRKzR_uPiSFLp3ruvgg&dqV2g$;u0LC|`73xRWK)pS>y*Z^^@ahVZ7E!Km z_>D?M!e9L7`b0Nc#}AJ1lwmUt0K5@kPe+PQunEr3sU!P?ig{6U59!ffS?Kepy4!`#ORgui1g;+{{7h^yPEfuu9i>d=oWX?^vvBP>Ddf zVxnoU88w$t`l|U=plZ0O_FU`b)M>x@U^d&{TWLJD*EYRoMTBsDMabZpAW%TR>deVk z3tizRgR10p!Pj^@315ZFhl$_gjU_+uToT`O=R;{clO&9uO9GM)m#L`Cb#ik;G>cOG z#)I8l0EzAV*rsZUI2mV6peUdGl^hIndk_~1`2$b!`=S2$Ab0^%){2nlo;P=i%Wtne z#k|f3r{8?XSLA)cjbhv+{`d#p`0?TN`|rQWp4ZR#%{Bfm10UcOlV}Skas--Zd@MfU zI6+6G|rigW-Z!DDqFppvE&;E^qBm7{ei}Y9eqF+;uGoQza zJB|6^m-qxTsW;!I8w-hG&V{;xF4`U9A)z62qLYpGDJa=MGW&WU>b!}u0SaN0Bu!mW z4Dd5)DnSuXi;x&Zj6+CJE~4ZUa9+Q_#it*BIQ{hFPp3DyE6Hyq@B%eEQg6f%S)WMd zaAw+ayP=(!C}e|PUQFTFNBMOr`xb;Y&i|SZG5|HNV#6C`gk{PN%bo__xM-}1U7{$MmGChFna z*liHA*F(3fyWsH~Ybk}T!AH;N${9o@PC6b!D4ev$)%>QtR!*3{qKeOhmRqG*sI2Bq zOZL9ndVM?0J|wT@`l1fx+C0}OW%-`AlKYltlx~~(%kj)_-%S{S8BnrN!B~_V2%-Yx zTIitR?jpw#NBWzW-<|&PkN@lR!}s6I6BxX?$i3pj<0)nn{ z{ZQLdAM*&zbI5RnGw&R0+^{DuFC=m=UukB&f~PsPuMss}6%)qDr2}XI;ZPe2`f8+n z{;2Yyp_f-?f`WtV##I5RX5>nGK999vlKG{@$hK12mS|v2$LJDpUpvQ6bzl`B`umqo zR2<21!PfGPy-U5_z1%szax=Q|bHRJ4$EQENa;^{NUKq{ds1m5nSqoj3GWnW4fa;Iw zf=MPE#7wVb0%(#u=o|i%2Oq@Nh$wxGD$iT-%!PbwQ(kAp3O0P_Krr|~Mz|6Ms-Q_b ze?#yEzU}$)DIUao{qA)8BYp!9Hyik4(R@|XFTeb9`p;#O=AZ-f_j4_V7wn!|?NS>NOeyx#1dmG(RB>+#>4%GnA5jC7Ow~+Ki{Q z7y01PR;-quecM`jHSZnz#m)CSDdc1u|E<}4H>TY^$5R{T^nSgRoy*_d;o8>R<$l<+ zajN}13PmqCFV&34dBhY{_BY2=Slf#T=0MD2R~*my@Ej7S9@BXz=l7?G4X@qdf|2}A z!ax50kJFF;^W*95ci)}(yE#0ov8#d%gIOXEmf_)5(Fbf^od5g<_JkV;#J2v7xyAEW zp8rJew7;mIv`F_6u$-iB+B3-qcAY|T&JA2%i@f7Rix1^Ew~NjY4}OE6YyKPX@`i*w z%!>!~UgN{_=^wu5x70r4Cd0?m&;R|M&mC0;e#wOs@UT<#!(QM=laXITp<;e`fkXHFy#RJ8H>C+3 zGWM=eP=!}m8h>c`+O1obD!d-%kXX;;V>#QzF})k7tr$3Rdy#RXXV_TB6&~>$7n1pd zEW8-Rc<3EQ#_L+X#`L!z|0YkfzIypeUTws?l6@j%I0$x? zysS$$Rju-ytxf6S?0NK*J$PlX{6X{OMx!U?BYV+1<~Q1VNs`*bC4Q6=PT7mWJh>Qx{MZa7(~RG z^DL5D`;~vK#0&X)Dc@+^%YuV(2In>3$@>}O5(_aIKXTl|T#d#nS?B>9`0Nr{3}QCt zx5~c9O@#mX+fS$G_)+EOxFP%s?+Ugb)MCfWm=p~har%$;b6oOwS*TqwIi8`1|M-8} zs1^*9uk+@KH4~fYQecRZIY4n!ru}N7jvD=dNZ@I$w>W9+t_T9-6C`BM9AHpqoAPO! zwksRxG8rmO#|VWZ29ETo#rh0-C4q?cSqV`UV;_Vib(QB*8gwM2)>KmmY{l+P#UE>D zh``_}*BLB&RB?_7l;`zu4DK|*&(V5#vZF>Pz$4pX5!kaQ46A+g46T^-l|TG7VN&s5 z-=mFl8yv5tNyuDdYrCA(umNy`?`&^ZEit{M&ufOrWKGa-d3B(@_b??Y>mE0PR|#q zy(K*pD06RHrO~dA9v0Q=7yDVyZCXp($Xm90JyowA_Q)3_lcs48%#6qSch=wt9KAe) z<4piQd@8?C94<1)c$+coU+_XkA7aD#0>%?Bs`H{b&mLre;(|HPC$B-f#+bUkL;VYH zHt=^5p5b>B-r=0`htoU!cEVe{LigEowBv_pI4AKRH)@n~5spb-|HCz;Tf7>M53NzB ztfe_Gp`h0ub)3)zIY)+^T*N02KJq(k;-8?Z4%#l7SOt*WEudz8tAep$1Tf3lynrU^ zqh-8aHEq-ZcKNWCqSz=+oNLAty5*)b<>Z7L$SBLV8pzD^Z}@rpiLbx=j2py%;QH8a zr=NfQ&*`_1znwmQ`FOg${sLJVfEx?oeFjDBJ-)<=+E}K&tW%ea8T1V|%>~NRKi%y5 zH)~ILBTQXqO&J=0C6pa}22ny>GuMbr0FyHNgr2KGrf#;U|4TfRkOU&e$>`HJ7L3r^ z&y|%zkRsTC^RhxiM=$s{0rd>}i8(89ob+$XoQkBi5*F>_Q;w2|ft?-5A{P`K6!cjx z6oJTu=DW0c@##C(a&x zQNl|u-F)05)mpI!zu(fDZz+_i%acQg`mCTm-5u?*jwpLydy~)G;pH8j{)(UMW1(}} z(oICHLK1HPVN$k!k(;Nq1s_hFya>rxr@X|?rI&bL-YZ38JGhd=P6&-hUr z{yqc00wy^pqR`6$9y-HxQf(jw`^?U~ON>ixKKifue*b!W$8cOOZ`#j{W8^hjq{I^W zQhzyS%9^yL-WE~!E9JIFzk-9#&q-_9L~SndPlD$vL?dD;>ds~@G`Jq2Z(M}n1~9j& z?=UtwfAi)ozsbNZVWiR{AM$$Ls_@lGCM&L1z zb`9r6f%P5V!Yl%2YLW436#b~NW5w{#U)|ts1PS&eN)F?I?Dv&W_ztkL{^71U|iZxdl`S3 zFZZ-*KAZA}9Wf=1dGp@R6?%bq*1&%>g?||~yw#>Sxq@>NE|_!NVm{G_ zg&~deOkRxVYm0bZNjhJb`{w0aoJ-=S!W+Em2=f%L13kxUbp-=63IB0?e!)d^zWzgsdX)Dm=;W(5J=1x2Zc+Cs{a!#$+PoKPeDmPkQyu>Sp z-@HD(`0nND^@mq@7#9!X{`I@OPW%?%)L^I(Wzj%58YT}BMnl9V%>%I*Oty)DRRE-O zzH!APKxCPfj2R6Q{u#qEfp(9|BD#`WbTi5E%%xm=`M8pES=~8jBM&~ea#CUn|E*PMH#b7NvyStx<-ScOmi5HHp? zl}sl=lH?5fH-YahyrWHwpZM!ACmQW`bbTt>;)4vrWGU-Y_8C6J;oQvZ;ti(n@U`AI zm^69Unl~2s&4pj^;VY9q;Rzf5fSDW=_{|`G^FeflWQ7sZyS5iofQ)AHrF}$0kKYoV zhpNj4Y(W>}ux8WlWDjc`4a4$3_t9c)d(8(ZU#Z{pt=*fQxqME~>k~g*&wXTDm46aE zX91Z%bujxX?XRgxObUG=EEs>--{Sp^y!lJM8!rCjgc1$-&pG`$e=k8!PT!sW_P4*` zeV5B54@ zhK9%8#t5LlrFU8|z9(9I+uM$u5{|^)?)f9aV+0-}@E8G&Kze+BjLKsK$`Rn&i5J%V zCTmk9&=X$J$Fbeks@&_%0Da>3e2fQZfKd1lw_rFiS;hW-r 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

    M^{Fe*`6Mb|-$i1wiOXJwB6xkFV+0Gkk0m=TnC6 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ΞZi<=~Zuo>Ng?k6{u5 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ð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

    hKp6FuPuPr0fC{u2;r!{u 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 J)^E4G{|{X&pcw!F literal 0 HcmV?d00001 diff --git a/docs/mkdocs/assets/imgs/local1.png b/docs/mkdocs/assets/imgs/local1.png new file mode 100644 index 0000000000000000000000000000000000000000..5eaf7bd4223ad8a0bdc9c00db8b4d597ef523c15 GIT binary patch literal 101370 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�LrX?W0}FyOajYf*4YU;% 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

    GppFmcST0S^Dp=6rW^OZjC*5CGWp`h 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 IR9iDu)T-KBYFEt`wRi2-s6AuUs1-FD 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

    `VX{Cw2q~YLER&MVVd5NI=)zE92UKJn) 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